Merge pull request #14 from KometTeam/feature/FullStack
Feature/full stack
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
name: Build Android
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- 'lib/**'
|
||||
- 'android/**'
|
||||
- 'pubspec.yaml'
|
||||
- '.github/workflows/build-android.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'lib/**'
|
||||
- 'android/**'
|
||||
- 'pubspec.yaml'
|
||||
- '.github/workflows/build-android.yml'
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.41.5'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Gradle
|
||||
run: |
|
||||
mkdir -p ~/.gradle
|
||||
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||
|
||||
- name: Build Universal APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Build Split APKs
|
||||
run: flutter build apk --release --split-per-abi
|
||||
|
||||
- name: Upload Universal APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komet-android-universal
|
||||
path: build/app/outputs/flutter-apk/app-release.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload arm64-v8a APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komet-android-arm64-v8a
|
||||
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload armeabi-v7a APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komet-android-armeabi-v7a
|
||||
path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload x86_64 APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komet-android-x86_64
|
||||
path: build/app/outputs/flutter-apk/app-x86_64-release.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Build App Bundle
|
||||
run: flutter build appbundle --release
|
||||
|
||||
- name: Upload App Bundle artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komet-android-aab
|
||||
path: build/app/outputs/bundle/release/app-release.aab
|
||||
retention-days: 30
|
||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"cmake.sourceDirectory": "/run/media/invisedivine/Drive/! My projects/Komet/linux/runner"
|
||||
"cmake.sourceDirectory": "/run/media/invisedivine/Drive/! My projects/Komet/linux/runner",
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Не оставляй комментарии в код
|
||||
Старайся писать чистый код
|
||||
Лучше качество чем количество
|
||||
Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно.
|
||||
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
|
||||
@@ -1,4 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<application
|
||||
android:label="komet"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
kotlin.incremental=false
|
||||
dev.steenbakker.mobile_scanner.useUnbundled=true
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
@@ -0,0 +1,62 @@
|
||||
PODS:
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_timezone (0.0.1):
|
||||
- Flutter
|
||||
- mobile_scanner (7.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
flutter_timezone:
|
||||
:path: ".symlinks/plugins/flutter_timezone/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -10,10 +10,12 @@
|
||||
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 */; };
|
||||
4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; };
|
||||
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 */; };
|
||||
E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -42,12 +44,19 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -55,13 +64,23 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
2594AD7A0161CA1A66191F05 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -76,6 +95,15 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3E066142FD515067AADAC58D /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */,
|
||||
2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -94,6 +122,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
C4C44F130EEB07A77FD05026 /* Pods */,
|
||||
3E066142FD515067AADAC58D /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -121,6 +151,20 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C4C44F130EEB07A77FD05026 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */,
|
||||
366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */,
|
||||
28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */,
|
||||
1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */,
|
||||
9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -128,8 +172,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
2594AD7A0161CA1A66191F05 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -145,12 +191,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -222,6 +270,23 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -238,6 +303,50 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -378,6 +487,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -395,6 +505,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -410,6 +521,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
||||
+3
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -45,5 +45,7 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
+161
-72
@@ -2,8 +2,10 @@ import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../core/config/config.dart';
|
||||
import '../core/config/countries.dart';
|
||||
import '../core/protocol/opcode_map.dart';
|
||||
import '../core/protocol/packet.dart';
|
||||
import '../core/storage/spoofing_service.dart';
|
||||
import '../core/transport/connection.dart';
|
||||
import '../core/transport/dispatcher.dart';
|
||||
import '../core/transport/receiver.dart';
|
||||
@@ -11,17 +13,11 @@ import '../core/transport/sender.dart';
|
||||
import '../core/utils/logger.dart';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'dart:io';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'dart:io';
|
||||
|
||||
enum SessionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
online
|
||||
}
|
||||
enum SessionState { disconnected, connecting, connected, online }
|
||||
|
||||
/// Клиент API.
|
||||
///
|
||||
@@ -34,8 +30,23 @@ class Api {
|
||||
|
||||
SessionState _sessionState = SessionState.disconnected;
|
||||
final _stateController = StreamController<SessionState>.broadcast();
|
||||
final _sessionExpiredController =
|
||||
StreamController<SessionExpiredException>.broadcast();
|
||||
final _handshakeSuccessController = StreamController<String>.broadcast();
|
||||
Map<dynamic, dynamic>? _userAgent;
|
||||
|
||||
Map<dynamic, dynamic>? get userAgent => _userAgent;
|
||||
|
||||
List<CountryName>? _registrationCountries;
|
||||
|
||||
List<CountryName> get registrationCountries =>
|
||||
_registrationCountries ?? allCountries;
|
||||
|
||||
Stream<SessionState> get stateStream => _stateController.stream;
|
||||
Stream<SessionExpiredException> get sessionExpiredStream =>
|
||||
_sessionExpiredController.stream;
|
||||
Stream<String> get handshakeSuccessStream =>
|
||||
_handshakeSuccessController.stream;
|
||||
SessionState get state => _sessionState;
|
||||
|
||||
StreamSubscription<Uint8List>? _dataSubscription;
|
||||
@@ -63,12 +74,15 @@ class Api {
|
||||
});
|
||||
|
||||
try {
|
||||
await _connection.connect(ServerConfig.host, ServerConfig.port);
|
||||
final endpoint = await ServerConfig.loadEndpoint();
|
||||
await _connection.connect(endpoint.host, endpoint.port);
|
||||
} catch (e) {
|
||||
logger.e('Не удалось подключиться: $e');
|
||||
_cleanup();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
if (_sessionState != SessionState.disconnected) {
|
||||
_cleanup();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,9 +92,16 @@ class Api {
|
||||
try {
|
||||
final response = await sendHandshake();
|
||||
if (response.isOk) {
|
||||
_registrationCountries = _parseRegistrationCountries(response.payload);
|
||||
_setSessionState(SessionState.online);
|
||||
_startPinging();
|
||||
logger.i('Сессия онлайн, хэндшейк ок');
|
||||
_handshakeSuccessController.add(
|
||||
response.payload['device_name'] as String? ?? 'Unknown',
|
||||
);
|
||||
if (_onReconnectCallback != null) {
|
||||
_onReconnectCallback!();
|
||||
}
|
||||
} else {
|
||||
logger.e('Хэндшейк отклонён: ${response.payload}');
|
||||
}
|
||||
@@ -98,86 +119,113 @@ class Api {
|
||||
_setSessionState(SessionState.disconnected);
|
||||
}
|
||||
|
||||
/// Отправляет хэндшейк (opcode 6).
|
||||
Future<Packet> sendHandshake() async {
|
||||
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
// Если платформа Linux или Windows, то ставим DESKTOP, если нет, то проверяем на Android или IOS;
|
||||
String deviceType = (Platform.isLinux || Platform.isWindows) ? "DESKTOP" : (Platform.isAndroid) ? "ANDROID" : "IOS";
|
||||
String osVersion = "";
|
||||
String deviceName = "Unknown";
|
||||
String architecture = "arm64";
|
||||
|
||||
tz.initializeTimeZones();
|
||||
|
||||
final now = DateTime.now();
|
||||
String timezone = "Europe/Moscow";
|
||||
String deviceType = (Platform.isLinux || Platform.isWindows)
|
||||
? 'DESKTOP'
|
||||
: (Platform.isAndroid)
|
||||
? 'ANDROID'
|
||||
: 'IOS';
|
||||
String osVersion = '';
|
||||
String deviceName = 'Unknown';
|
||||
String architecture = 'arm64';
|
||||
String appVersion = SpoofingService.hardcodedAppVersion;
|
||||
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
||||
String screen = '1920x1080';
|
||||
|
||||
tz.initializeTimeZones();
|
||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||
timezone = timeZoneName.identifier;
|
||||
String timezone = timeZoneName.identifier;
|
||||
String locale = 'ru';
|
||||
String deviceLocale = Platform.localeName.substring(0, 2);
|
||||
String deviceId = 'a1b2c3d4e5f6a7b8';
|
||||
|
||||
// На каждой платформе свое инфо, поэтому делаем такую проверку
|
||||
if (Platform.isLinux) {
|
||||
LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo;
|
||||
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
osVersion = linuxInfo.name;
|
||||
// Platform.version содержит в себе что-то такое
|
||||
// 3.11.1 (stable) (Tue Feb 24 00:03:07 2026 -0800) on "linux_x64"
|
||||
// Поэтому мы находим '_', прибавляем к его индексу 1 и берем символы до length - 1
|
||||
architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1);
|
||||
architecture = Platform.version.substring(
|
||||
Platform.version.indexOf('_') + 1,
|
||||
Platform.version.length - 1,
|
||||
);
|
||||
} else if (Platform.isIOS) {
|
||||
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
|
||||
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
osVersion = iosInfo.systemVersion;
|
||||
deviceName = iosInfo.utsname.machine;
|
||||
} else if (Platform.isAndroid) {
|
||||
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
||||
|
||||
osVersion = "Android ${androidInfo.version.release}";
|
||||
deviceName = "${androidInfo.manufacturer} ${androidInfo.model}";
|
||||
architecture = androidInfo.supportedAbis.first;
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
osVersion = 'Android ${androidInfo.version.release}';
|
||||
deviceName = '${androidInfo.manufacturer} ${androidInfo.model}';
|
||||
architecture = androidInfo.supportedAbis.first;
|
||||
} else if (Platform.isWindows) {
|
||||
WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo;
|
||||
|
||||
final windowsInfo = await deviceInfo.windowsInfo;
|
||||
osVersion = windowsInfo.productName;
|
||||
architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1);
|
||||
architecture = Platform.version.substring(
|
||||
Platform.version.indexOf('_') + 1,
|
||||
Platform.version.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
print(deviceType);
|
||||
|
||||
final spoofed = await SpoofingService.getSpoofedSessionData();
|
||||
if (spoofed != null) {
|
||||
deviceType = (spoofed['device_type'] as String?) ?? deviceType;
|
||||
final sDeviceName = spoofed['device_name'] as String?;
|
||||
if (sDeviceName != null && sDeviceName.isNotEmpty) {
|
||||
deviceName = sDeviceName;
|
||||
}
|
||||
final sOsVersion = spoofed['os_version'] as String?;
|
||||
if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion;
|
||||
final sScreen = spoofed['screen'] as String?;
|
||||
if (sScreen != null && sScreen.isNotEmpty) screen = sScreen;
|
||||
final sTimezone = spoofed['timezone'] as String?;
|
||||
if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone;
|
||||
final sLocale = spoofed['locale'] as String?;
|
||||
if (sLocale != null && sLocale.isNotEmpty) {
|
||||
locale = sLocale;
|
||||
deviceLocale = sLocale.split(RegExp(r'[-_]')).first;
|
||||
}
|
||||
final sDeviceId = spoofed['device_id'] as String?;
|
||||
if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId;
|
||||
appVersion = (spoofed['app_version'] as String?) ?? appVersion;
|
||||
architecture = (spoofed['arch'] as String?) ?? architecture;
|
||||
final sBuild = spoofed['build_number'];
|
||||
if (sBuild is int) {
|
||||
buildNumber = sBuild;
|
||||
} else if (sBuild is String) {
|
||||
buildNumber = int.tryParse(sBuild) ?? buildNumber;
|
||||
}
|
||||
}
|
||||
|
||||
_userAgent = {
|
||||
'deviceType': deviceType,
|
||||
'locale': locale,
|
||||
'deviceLocale': deviceLocale,
|
||||
'osVersion': osVersion,
|
||||
'deviceName': deviceName,
|
||||
'appVersion': appVersion,
|
||||
'screen': screen,
|
||||
'timezone': timezone,
|
||||
'pushDeviceType': 'GCM',
|
||||
'arch': architecture,
|
||||
'buildNumber': buildNumber,
|
||||
};
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000',
|
||||
'clientSessionId': 42,
|
||||
'deviceId': 'a1b2c3d4e5f6a7b8',
|
||||
'userAgent': {
|
||||
'deviceType': deviceType,
|
||||
// Первые два символа из locale это и есть нужный нам аргумент
|
||||
'locale': "ru",
|
||||
'deviceLocale': Platform.localeName.substring(0, 2),
|
||||
'osVersion': osVersion,
|
||||
'deviceName': deviceName,
|
||||
'appVersion': '26.8.1',
|
||||
'screen': '1920x1080',
|
||||
// 'screen': screenSize.width + 'x' + screenSize.height,
|
||||
'timezone': timezone,
|
||||
'pushDeviceType': 'GCM',
|
||||
'arch': architecture,
|
||||
'buildNumber': 6606,
|
||||
},
|
||||
'deviceId': deviceId,
|
||||
'userAgent': _userAgent,
|
||||
};
|
||||
|
||||
print(payload);
|
||||
print(Platform.version);
|
||||
return sendRequest(Opcode.sessionInit, payload);
|
||||
}
|
||||
|
||||
/// Отправляет запрос и ждёт ответ от сервера.
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload,
|
||||
) {
|
||||
Future<Packet> sendRequest(int opcode, Map<dynamic, dynamic> payload) {
|
||||
final seq = _sender.send(_connection, opcode, payload);
|
||||
return _dispatcher.registerPending(seq).timeout(
|
||||
return _dispatcher
|
||||
.registerPending(seq)
|
||||
.timeout(
|
||||
ServerConfig.requestTimeout,
|
||||
onTimeout: () =>
|
||||
throw TimeoutException('${Opcode.name(opcode)} таймаут'),
|
||||
@@ -199,11 +247,11 @@ class Api {
|
||||
_dispatcher.dispose();
|
||||
_connection.dispose();
|
||||
_stateController.close();
|
||||
_sessionExpiredController.close();
|
||||
}
|
||||
|
||||
// Внутрянка
|
||||
|
||||
|
||||
void _setSessionState(SessionState state) {
|
||||
if (_sessionState == state) return;
|
||||
_sessionState = state;
|
||||
@@ -211,8 +259,16 @@ class Api {
|
||||
logger.i('Сессия: ${state.name}');
|
||||
}
|
||||
|
||||
void _onDataReceived(Uint8List data) {
|
||||
for (final packet in _receiver.feed(data)) {
|
||||
Future<void> _onDataReceived(Uint8List data) async {
|
||||
await for (final packet in _receiver.feed(data)) {
|
||||
if (packet.isError &&
|
||||
packet.payload is Map &&
|
||||
(packet.payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
||||
packet.payload['message'] == 'FAIL_WRONG_PASSWORD')) {
|
||||
_sessionExpiredController.add(
|
||||
SessionExpiredException(messageFromErrorPayload(packet.payload)),
|
||||
);
|
||||
}
|
||||
_dispatcher.dispatch(packet);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +287,17 @@ class Api {
|
||||
_socketStateSubscription = null;
|
||||
_receiver.reset();
|
||||
_dispatcher.clearPending();
|
||||
_handshakeSuccessController.add('disconnected');
|
||||
}
|
||||
|
||||
Future<void> reconnectAndLogin() async {
|
||||
await connect();
|
||||
}
|
||||
|
||||
void Function()? _onReconnectCallback;
|
||||
|
||||
void setReconnectCallback(void Function() callback) {
|
||||
_onReconnectCallback = callback;
|
||||
}
|
||||
|
||||
void _startPinging() {
|
||||
@@ -242,6 +309,28 @@ class Api {
|
||||
});
|
||||
}
|
||||
|
||||
static List<CountryName>? _parseRegistrationCountries(dynamic payload) {
|
||||
if (payload is! Map) return null;
|
||||
final raw = payload['reg-country-code'];
|
||||
if (raw is! List || raw.isEmpty) return null;
|
||||
final codes = <String>[];
|
||||
for (final e in raw) {
|
||||
if (e is String && e.isNotEmpty) codes.add(e.toUpperCase());
|
||||
}
|
||||
if (codes.isEmpty) return null;
|
||||
var list = countriesInServerOrder(codes);
|
||||
if (list.isEmpty) return null;
|
||||
|
||||
final loc = payload['location'];
|
||||
if (loc is String && loc.length == 2) {
|
||||
final home = countriesByCode[loc.toUpperCase()];
|
||||
if (home != null && !list.any((c) => c.code == home.code)) {
|
||||
list = [home, ...list];
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_reconnectAttempts >= ServerConfig.maxReconnectAttempts) {
|
||||
logger.e('Лимит попыток реконнекта');
|
||||
@@ -250,7 +339,7 @@ class Api {
|
||||
|
||||
final delaySec = (2 * (1 << _reconnectAttempts)).clamp(2, 30);
|
||||
_reconnectAttempts++;
|
||||
logger.i('Реконнект через ${delaySec}с (попытка $_reconnectAttempts)');
|
||||
logger.i('Реконнект через $delaySecс (попытка $_reconnectAttempts)');
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(Duration(seconds: delaySec), connect);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
class ChatFolder {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? emoji;
|
||||
final List<int>? include;
|
||||
final List<dynamic> filters;
|
||||
final bool hideEmpty;
|
||||
final List<ChatFolderWidget> widgets;
|
||||
final List<int>? favorites;
|
||||
final Map<String, dynamic>? filterSubjects;
|
||||
final List<int>? 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<String, dynamic> json) {
|
||||
return ChatFolder(
|
||||
id: json['id'].toString(),
|
||||
title: json['title']?.toString() ?? '',
|
||||
emoji: json['emoji']?.toString(),
|
||||
include: (json['include'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
})
|
||||
.toList(),
|
||||
filters:
|
||||
(json['filters'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? e;
|
||||
return e;
|
||||
})
|
||||
.toList() ??
|
||||
[],
|
||||
hideEmpty: json['hideEmpty'] ?? false,
|
||||
widgets:
|
||||
(json['widgets'] as List<dynamic>?)
|
||||
?.map((w) {
|
||||
if (w is Map<String, dynamic>) {
|
||||
return ChatFolderWidget.fromJson(w);
|
||||
}
|
||||
return ChatFolderWidget.fromJson(
|
||||
Map<String, dynamic>.from(w as Map),
|
||||
);
|
||||
})
|
||||
.toList() ??
|
||||
[],
|
||||
favorites: (json['favorites'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
})
|
||||
.toList(),
|
||||
filterSubjects: json['filterSubjects'] is Map<String, dynamic>
|
||||
? json['filterSubjects'] as Map<String, dynamic>
|
||||
: (json['filterSubjects'] is Map
|
||||
? Map<String, dynamic>.from(
|
||||
(json['filterSubjects'] as Map).cast<dynamic, dynamic>(),
|
||||
)
|
||||
: null),
|
||||
options: (json['options'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
})
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
if (emoji != null) 'emoji': emoji,
|
||||
if (include != null) 'include': include,
|
||||
'filters': filters,
|
||||
'hideEmpty': hideEmpty,
|
||||
'widgets': widgets.map((w) => w.toJson()).toList(),
|
||||
if (favorites != null) 'favorites': favorites,
|
||||
if (filterSubjects != null) 'filterSubjects': filterSubjects,
|
||||
if (options != null) 'options': options,
|
||||
};
|
||||
}
|
||||
|
||||
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<String, dynamic> json) {
|
||||
final rawId = json['id'];
|
||||
return ChatFolderWidget(
|
||||
id: rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '') ?? 0,
|
||||
name: json['name']?.toString() ?? '',
|
||||
description: json['description']?.toString() ?? '',
|
||||
iconUrl: json['iconUrl']?.toString(),
|
||||
url: json['url']?.toString(),
|
||||
startParam: json['startParam']?.toString(),
|
||||
background: json['background']?.toString(),
|
||||
appId: json['appId'] is int
|
||||
? json['appId'] as int
|
||||
: int.tryParse(json['appId']?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
if (iconUrl != null) 'iconUrl': iconUrl,
|
||||
if (url != null) 'url': url,
|
||||
if (startParam != null) 'startParam': startParam,
|
||||
if (background != null) 'background': background,
|
||||
if (appId != null) 'appId': appId,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
@@ -5,6 +7,191 @@ import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'chats.dart';
|
||||
import 'contacts.dart';
|
||||
import 'folders.dart';
|
||||
|
||||
String _normalizeAuthPhone(String phone) {
|
||||
final digits = phone.replaceAll(RegExp(r'\D'), '');
|
||||
return '+$digits';
|
||||
}
|
||||
|
||||
class PrivacyConfig {
|
||||
final String searchByPhone;
|
||||
final String incomingCall;
|
||||
final bool doubleTapReactionDisabled;
|
||||
final bool safeModeNoPin;
|
||||
final String? doubleTapReactionValue;
|
||||
final String familyProtection;
|
||||
final bool pushDetails;
|
||||
final bool hidden;
|
||||
final String chatsInvite;
|
||||
final bool pushNewContacts;
|
||||
final bool unsafeFiles;
|
||||
final String inactiveTtl;
|
||||
final bool showReadMark;
|
||||
final bool altKeyboard;
|
||||
final bool contentLevelAccess;
|
||||
final String stickersSuggest;
|
||||
final bool safeMode;
|
||||
final bool audioTranscriptionEnabled;
|
||||
final String hash;
|
||||
|
||||
const PrivacyConfig({
|
||||
required this.searchByPhone,
|
||||
required this.incomingCall,
|
||||
required this.doubleTapReactionDisabled,
|
||||
required this.safeModeNoPin,
|
||||
this.doubleTapReactionValue,
|
||||
required this.familyProtection,
|
||||
required this.pushDetails,
|
||||
required this.hidden,
|
||||
required this.chatsInvite,
|
||||
required this.pushNewContacts,
|
||||
required this.unsafeFiles,
|
||||
required this.inactiveTtl,
|
||||
required this.showReadMark,
|
||||
required this.altKeyboard,
|
||||
required this.contentLevelAccess,
|
||||
required this.stickersSuggest,
|
||||
required this.safeMode,
|
||||
required this.audioTranscriptionEnabled,
|
||||
required this.hash,
|
||||
});
|
||||
|
||||
factory PrivacyConfig.fromMap(Map<dynamic, dynamic> map) {
|
||||
return PrivacyConfig(
|
||||
searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL',
|
||||
incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS',
|
||||
doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false,
|
||||
safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false,
|
||||
doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(),
|
||||
familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF',
|
||||
pushDetails: map['PUSH_DETAILS'] ?? false,
|
||||
hidden: map['HIDDEN'] ?? true,
|
||||
chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS',
|
||||
pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false,
|
||||
unsafeFiles: map['UNSAFE_FILES'] ?? true,
|
||||
inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M',
|
||||
showReadMark: map['SHOW_READ_MARK'] ?? true,
|
||||
altKeyboard: map['ALT_KEYBOARD'] ?? false,
|
||||
contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false,
|
||||
stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON',
|
||||
safeMode: map['SAFE_MODE'] ?? false,
|
||||
audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true,
|
||||
hash: map['hash']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => jsonEncode({
|
||||
'SEARCH_BY_PHONE': searchByPhone,
|
||||
'INCOMING_CALL': incomingCall,
|
||||
'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled,
|
||||
'SAFE_MODE_NO_PIN': safeModeNoPin,
|
||||
'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue,
|
||||
'FAMILY_PROTECTION': familyProtection,
|
||||
'PUSH_DETAILS': pushDetails,
|
||||
'HIDDEN': hidden,
|
||||
'CHATS_INVITE': chatsInvite,
|
||||
'PUSH_NEW_CONTACTS': pushNewContacts,
|
||||
'UNSAFE_FILES': unsafeFiles,
|
||||
'INACTIVE_TTL': inactiveTtl,
|
||||
'SHOW_READ_MARK': showReadMark,
|
||||
'ALT_KEYBOARD': altKeyboard,
|
||||
'CONTENT_LEVEL_ACCESS': contentLevelAccess,
|
||||
'STICKERS_SUGGEST': stickersSuggest,
|
||||
'SAFE_MODE': safeMode,
|
||||
'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled,
|
||||
'hash': hash,
|
||||
});
|
||||
|
||||
factory PrivacyConfig.fromJson(String json) {
|
||||
try {
|
||||
final map = jsonDecode(json) as Map<String, dynamic>;
|
||||
return PrivacyConfig.fromMap(map);
|
||||
} catch (_) {
|
||||
return PrivacyConfig.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static PrivacyConfig empty() {
|
||||
return const PrivacyConfig(
|
||||
searchByPhone: 'ALL',
|
||||
incomingCall: 'CONTACTS',
|
||||
doubleTapReactionDisabled: false,
|
||||
safeModeNoPin: false,
|
||||
familyProtection: 'OFF',
|
||||
pushDetails: false,
|
||||
hidden: true,
|
||||
chatsInvite: 'CONTACTS',
|
||||
pushNewContacts: false,
|
||||
unsafeFiles: true,
|
||||
inactiveTtl: '6M',
|
||||
showReadMark: true,
|
||||
altKeyboard: false,
|
||||
contentLevelAccess: false,
|
||||
stickersSuggest: 'ON',
|
||||
safeMode: false,
|
||||
audioTranscriptionEnabled: true,
|
||||
hash: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BlockedContact {
|
||||
final int id;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? baseUrl;
|
||||
final int? photoId;
|
||||
final String status;
|
||||
final int registrationTime;
|
||||
final int updateTime;
|
||||
|
||||
const BlockedContact({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.baseUrl,
|
||||
this.photoId,
|
||||
required this.status,
|
||||
required this.registrationTime,
|
||||
required this.updateTime,
|
||||
});
|
||||
|
||||
factory BlockedContact.fromMap(Map<dynamic, dynamic> map) {
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
final names = map['names'] as List?;
|
||||
if (names != null && names.isNotEmpty) {
|
||||
for (final n in names) {
|
||||
if (n is Map) {
|
||||
firstName = n['firstName'] as String?;
|
||||
lastName = n['lastName'] as String?;
|
||||
if (n['type'] == 'ONEME') break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BlockedContact(
|
||||
id: map['id'] as int? ?? 0,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
photoId: map['photoId'] as int?,
|
||||
status: map['status']?.toString() ?? 'BLOCKED',
|
||||
registrationTime: map['registrationTime'] as int? ?? 0,
|
||||
updateTime: map['updateTime'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TwoFactorDetails {
|
||||
final bool enabled;
|
||||
final String? email;
|
||||
final String? hint;
|
||||
|
||||
const TwoFactorDetails({required this.enabled, this.email, this.hint});
|
||||
}
|
||||
|
||||
enum AuthRequestType {
|
||||
startAuth('START_AUTH'),
|
||||
@@ -16,6 +203,8 @@ enum AuthRequestType {
|
||||
final String value;
|
||||
}
|
||||
|
||||
enum LoginStatus { idle, loading, success, error }
|
||||
|
||||
class RequestCodeResult {
|
||||
final String token;
|
||||
|
||||
@@ -38,10 +227,8 @@ class VerifyCodeResult {
|
||||
return c is Map ? c.cast<dynamic, dynamic>() : null;
|
||||
}
|
||||
|
||||
/// trackId из passwordChallenge — передаётся в [AccountModule.checkPassword].
|
||||
String? get challengeTrackId => passwordChallenge?['trackId'] as String?;
|
||||
|
||||
/// Подсказка к паролю из passwordChallenge.
|
||||
String? get challengeHint => passwordChallenge?['hint'] as String?;
|
||||
|
||||
int? get accountId {
|
||||
@@ -67,8 +254,6 @@ class TwoFactorResult {
|
||||
const TwoFactorResult({required this.loginToken});
|
||||
}
|
||||
|
||||
/// При отсутствии [LoginSyncParams] в [AccountModule.login] сервер вернёт
|
||||
/// полный снимок данных (cold start), иначе только дельту (warm start).
|
||||
class LoginSyncParams {
|
||||
final int chatsSync;
|
||||
final int contactsSync;
|
||||
@@ -111,34 +296,382 @@ class LoginSyncParams {
|
||||
}
|
||||
}
|
||||
|
||||
class SessionInfo {
|
||||
final int? id;
|
||||
final String client;
|
||||
final String location;
|
||||
final bool current;
|
||||
final int time;
|
||||
final String info;
|
||||
|
||||
const SessionInfo({
|
||||
this.id,
|
||||
required this.client,
|
||||
required this.location,
|
||||
required this.current,
|
||||
required this.time,
|
||||
required this.info,
|
||||
});
|
||||
|
||||
factory SessionInfo.fromMap(Map<dynamic, dynamic> map) {
|
||||
return SessionInfo(
|
||||
id: map['id'] is int
|
||||
? map['id']
|
||||
: (int.tryParse(map['id']?.toString() ?? '')),
|
||||
client: map['client'] ?? '',
|
||||
location: map['location'] ?? '',
|
||||
current: map['current'] ?? false,
|
||||
time: map['time'] ?? 0,
|
||||
info: map['info'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
int get uniqueId => Object.hash(id, client, time, info);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SessionInfo &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
client == other.client &&
|
||||
location == other.location &&
|
||||
current == other.current &&
|
||||
time == other.time &&
|
||||
info == other.info;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, client, location, current, time, info);
|
||||
}
|
||||
|
||||
class LoginResult {
|
||||
final ProfileData profile;
|
||||
final String? updatedToken;
|
||||
final int serverTime;
|
||||
final Map<dynamic, dynamic> raw;
|
||||
|
||||
const LoginResult({
|
||||
required this.profile,
|
||||
required this.updatedToken,
|
||||
required this.serverTime,
|
||||
required this.raw,
|
||||
});
|
||||
}
|
||||
|
||||
class AccountModule {
|
||||
final Api _api;
|
||||
final _loginStatusController = StreamController<LoginStatus>.broadcast();
|
||||
|
||||
AccountModule(this._api);
|
||||
|
||||
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
|
||||
|
||||
Future<PrivacyConfig> getPrivacyConfig() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
final saved = await AppDatabase.getPrivacyConfig(accountId);
|
||||
if (saved != null) {
|
||||
return PrivacyConfig.fromJson(saved);
|
||||
}
|
||||
}
|
||||
return PrivacyConfig.empty();
|
||||
}
|
||||
|
||||
Future<List<BlockedContact>> getBlockedContacts() async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.contactList, {
|
||||
'status': 'BLOCKED',
|
||||
'count': 100,
|
||||
'from': 0,
|
||||
});
|
||||
_checkPacketError(packet, 'getBlockedContacts');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'getBlockedContacts: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
final contacts = data['contacts'] as List?;
|
||||
if (contacts == null) return [];
|
||||
return contacts
|
||||
.whereType<Map>()
|
||||
.map((c) => BlockedContact.fromMap(c.cast<dynamic, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<PrivacyConfig> updatePrivacyConfig(
|
||||
Map<String, dynamic> settings,
|
||||
) async {
|
||||
_ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'settings': {'user': settings},
|
||||
};
|
||||
final packet = await _api.sendRequest(Opcode.config, payload);
|
||||
_checkPacketError(packet, 'updatePrivacyConfig');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'updatePrivacyConfig: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
final user = data['user'];
|
||||
if (user is! Map) {
|
||||
throw Exception('updatePrivacyConfig: отсутствует user в payload');
|
||||
}
|
||||
final config = PrivacyConfig.fromMap(user.cast<dynamic, dynamic>());
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.savePrivacyConfig(accountId, config.toJson());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// 2FA Creation (when not set)
|
||||
Future<String> create2faTrack() async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0});
|
||||
_checkPacketError(packet, 'create2faTrack');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'create2faTrack: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
final trackId = data['trackId'] as String?;
|
||||
if (trackId == null) {
|
||||
throw Exception('create2faTrack: отсутствует trackId');
|
||||
}
|
||||
return trackId;
|
||||
}
|
||||
|
||||
Future<void> set2faPassword(String trackId, String password) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authValidatePassword, {
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
});
|
||||
_checkPacketError(packet, 'set2faPassword');
|
||||
if (packet.payload != null && packet.payload is! Map) {
|
||||
throw Exception('set2faPassword: неожиданный ответ');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set2faHint(String trackId, String hint) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authValidateHint, {
|
||||
'trackId': trackId,
|
||||
'hint': hint,
|
||||
});
|
||||
_checkPacketError(packet, 'set2faHint');
|
||||
if (packet.payload != null && packet.payload is! Map) {
|
||||
throw Exception('set2faHint: неожиданный ответ');
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> verify2faEmail(String trackId, String email) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authVerifyEmail, {
|
||||
'trackId': trackId,
|
||||
'email': email,
|
||||
});
|
||||
_checkPacketError(packet, 'verify2faEmail');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'verify2faEmail: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
final blockingDuration = data['blockingDuration'] as int? ?? 60;
|
||||
return blockingDuration;
|
||||
}
|
||||
|
||||
Future<String> verify2faCode(String trackId, String code) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authCheckEmail, {
|
||||
'trackId': trackId,
|
||||
'verifyCode': code,
|
||||
});
|
||||
_checkPacketError(packet, 'verify2faCode');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'verify2faCode: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
final email = data['email'] as String? ?? '';
|
||||
return email;
|
||||
}
|
||||
|
||||
Future<ProfileData> confirm2fa({
|
||||
required String trackId,
|
||||
required String password,
|
||||
String? hint,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [0, 3, 4],
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'confirm2fa');
|
||||
return _processProfileUpdate(packet);
|
||||
}
|
||||
|
||||
// 2FA Management (when already set)
|
||||
Future<String> enter2faPanel() async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0});
|
||||
_checkPacketError(packet, 'enter2faPanel');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'enter2faPanel: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
final trackId = data['trackId'] as String?;
|
||||
if (trackId == null) {
|
||||
throw Exception('enter2faPanel: отсутствует trackId');
|
||||
}
|
||||
return trackId;
|
||||
}
|
||||
|
||||
Future<TwoFactorDetails> get2faDetails(String trackId) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.auth2faDetails, {
|
||||
'trackId': trackId,
|
||||
});
|
||||
_checkPacketError(packet, 'get2faDetails');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception(
|
||||
'get2faDetails: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
final password = data['password'] as Map?;
|
||||
return TwoFactorDetails(
|
||||
enabled: password?['enabled'] ?? false,
|
||||
email: password?['email'] as String?,
|
||||
hint: password?['hint'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> check2faPassword(String trackId, String password) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, {
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
});
|
||||
_checkPacketError(packet, 'check2faPassword');
|
||||
final data = packet.payload;
|
||||
if (data is Map && data['error'] != null) {
|
||||
throw Exception('Неверный пароль');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProfileData> update2faPassword({
|
||||
required String trackId,
|
||||
required String newPassword,
|
||||
String? hint,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
final validatePacket = await _api.sendRequest(Opcode.authValidatePassword, {
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
});
|
||||
_checkPacketError(validatePacket, 'update2faPassword: validate');
|
||||
if (validatePacket.payload != null && validatePacket.payload is! Map) {
|
||||
throw Exception('update2faPassword: неожиданный ответ при валидации');
|
||||
}
|
||||
|
||||
if (hint != null) {
|
||||
final hintPacket = await _api.sendRequest(Opcode.authValidateHint, {
|
||||
'trackId': trackId,
|
||||
'hint': hint,
|
||||
});
|
||||
_checkPacketError(hintPacket, 'update2faPassword: hint');
|
||||
}
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [1, 3],
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'update2faPassword');
|
||||
return _processProfileUpdate(packet);
|
||||
}
|
||||
|
||||
Future<ProfileData> update2faEmail({
|
||||
required String trackId,
|
||||
required String email,
|
||||
required String code,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
final verifyPacket = await _api.sendRequest(Opcode.authVerifyEmail, {
|
||||
'trackId': trackId,
|
||||
'email': email,
|
||||
});
|
||||
_checkPacketError(verifyPacket, 'update2faEmail: verify');
|
||||
|
||||
final codePacket = await _api.sendRequest(Opcode.authCheckEmail, {
|
||||
'trackId': trackId,
|
||||
'verifyCode': code,
|
||||
});
|
||||
_checkPacketError(codePacket, 'update2faEmail: code');
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [4],
|
||||
'trackId': trackId,
|
||||
};
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'update2faEmail');
|
||||
return _processProfileUpdate(packet);
|
||||
}
|
||||
|
||||
Future<ProfileData> remove2fa(String trackId) async {
|
||||
_ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [5],
|
||||
'trackId': trackId,
|
||||
'remove2fa': true,
|
||||
};
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'remove2fa');
|
||||
return _processProfileUpdate(packet);
|
||||
}
|
||||
|
||||
Future<ProfileData> _processProfileUpdate(Packet packet) async {
|
||||
_api.registerPushHandler(Opcode.notifProfile, (p) {});
|
||||
await for (final push in _api.pushStream.where(
|
||||
(p) => p.opcode == Opcode.notifProfile,
|
||||
)) {
|
||||
final payload = push.payload;
|
||||
if (payload is Map) {
|
||||
final profile = payload['profile'];
|
||||
if (profile is Map) {
|
||||
final contact = profile['contact'];
|
||||
if (contact is Map) {
|
||||
return ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw Exception('Не удалось получить обновлённый профиль');
|
||||
}
|
||||
|
||||
Future<RequestCodeResult> requestCode(
|
||||
String phone, {
|
||||
String language = 'ru',
|
||||
}) =>
|
||||
_requestCodeInternal(phone, AuthRequestType.startAuth, language);
|
||||
}) => _requestCodeInternal(phone, AuthRequestType.startAuth, language);
|
||||
|
||||
Future<RequestCodeResult> resendCode(
|
||||
String phone, {
|
||||
String language = 'ru',
|
||||
}) =>
|
||||
_requestCodeInternal(phone, AuthRequestType.resend, language);
|
||||
}) => _requestCodeInternal(phone, AuthRequestType.resend, language);
|
||||
|
||||
Future<VerifyCodeResult> verifyCode(String code, String token) async {
|
||||
_ensureOnline();
|
||||
@@ -157,7 +690,9 @@ class AccountModule {
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}');
|
||||
throw Exception(
|
||||
'verifyCode: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
|
||||
@@ -168,7 +703,6 @@ class AccountModule {
|
||||
if (sessionToken != null && accountId != null) {
|
||||
await TokenStorage.saveToken(sessionToken, accountId);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
logger.i('Токен аккаунта $accountId сохранён, установлен активным');
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -181,7 +715,8 @@ class AccountModule {
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
|
||||
final resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId();
|
||||
final resolvedAccountId =
|
||||
accountId ?? await TokenStorage.getActiveAccountId();
|
||||
if (resolvedAccountId == null) {
|
||||
throw StateError('login: нет активного аккаунта');
|
||||
}
|
||||
@@ -191,27 +726,63 @@ class AccountModule {
|
||||
throw StateError('login: нет токена для аккаунта $resolvedAccountId');
|
||||
}
|
||||
|
||||
final resolvedSyncParams =
|
||||
syncParams ?? await LoginSyncParams.fromDatabase(resolvedAccountId);
|
||||
final requestPayload = _buildLoginPayload(authToken, syncParams);
|
||||
|
||||
final requestPayload = _buildLoginPayload(authToken, resolvedSyncParams);
|
||||
_loginStatusController.add(LoginStatus.loading);
|
||||
try {
|
||||
final packet = await _api.sendRequest(Opcode.login, requestPayload);
|
||||
|
||||
logger.i('LOGIN opcode=${Opcode.login} '
|
||||
'account=$resolvedAccountId warm=${resolvedSyncParams != null}');
|
||||
_checkPacketError(packet, 'login');
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.login, requestPayload);
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('login: неожиданный тип payload: ${data.runtimeType}');
|
||||
}
|
||||
|
||||
_checkPacketError(packet, 'login');
|
||||
final result = await _processLoginResponse(
|
||||
data.cast<dynamic, dynamic>(),
|
||||
resolvedAccountId,
|
||||
);
|
||||
_loginStatusController.add(LoginStatus.success);
|
||||
return result;
|
||||
} catch (e) {
|
||||
_loginStatusController.add(LoginStatus.error);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SessionInfo>> getSessions() async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.sessionsInfo, {});
|
||||
_checkPacketError(packet, 'getSessions');
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('login: неожиданный тип payload: ${data.runtimeType}');
|
||||
if (data is! Map || data['sessions'] is! List) return [];
|
||||
final sessions = data['sessions'] as List;
|
||||
return sessions
|
||||
.map((s) => SessionInfo.fromMap(s as Map<dynamic, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> terminateOtherSessions() async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.sessionsClose, {});
|
||||
_checkPacketError(packet, 'terminateOtherSessions');
|
||||
}
|
||||
|
||||
Future<void> authorizeWebQrLogin(String qrLink) async {
|
||||
_ensureOnline();
|
||||
final link = qrLink.trim();
|
||||
if (link.isEmpty) {
|
||||
throw ArgumentError('Пустая ссылка из QR');
|
||||
}
|
||||
|
||||
return _processLoginResponse(
|
||||
data.cast<dynamic, dynamic>(),
|
||||
resolvedAccountId,
|
||||
);
|
||||
await _api.sendRequest(Opcode.ping, {'interactive': true});
|
||||
await _api.sendRequest(Opcode.sessionsInfo, {});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
final packet = await _api.sendRequest(Opcode.authQrApprove, {
|
||||
'qrLink': link,
|
||||
});
|
||||
_checkPacketError(packet, 'authorizeWebQrLogin');
|
||||
}
|
||||
|
||||
Future<ProfileData> switchAccount(int accountId) async {
|
||||
@@ -233,13 +804,6 @@ class AccountModule {
|
||||
logger.i('Аккаунт $accountId удалён локально');
|
||||
}
|
||||
|
||||
/// Проверяет 2FA-пароль (opcode 115).
|
||||
///
|
||||
/// [trackId] — из [VerifyCodeResult.challengeTrackId].
|
||||
/// [accountId] — из [VerifyCodeResult.accountId].
|
||||
///
|
||||
/// При неверном пароле бросает [Exception].
|
||||
/// При успехе сохраняет токен и устанавливает аккаунт активным.
|
||||
Future<TwoFactorResult> checkPassword({
|
||||
required String password,
|
||||
required String trackId,
|
||||
@@ -262,7 +826,9 @@ class AccountModule {
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('checkPassword: неожиданный тип payload: ${data.runtimeType}');
|
||||
throw Exception(
|
||||
'checkPassword: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
if (data['error'] != null) {
|
||||
@@ -310,7 +876,8 @@ class AccountModule {
|
||||
) {
|
||||
final payload = <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'interactive': true
|
||||
'interactive': true,
|
||||
if (_api.userAgent != null) 'userAgent': _api.userAgent,
|
||||
};
|
||||
|
||||
if (sync != null) {
|
||||
@@ -342,7 +909,6 @@ class AccountModule {
|
||||
final updatedToken = data['token'] as String?;
|
||||
if (updatedToken != null) {
|
||||
await TokenStorage.saveToken(updatedToken, accountId);
|
||||
logger.i('Обновлённый токен аккаунта $accountId сохранён');
|
||||
}
|
||||
|
||||
final profileMap = data['profile'];
|
||||
@@ -356,15 +922,29 @@ class AccountModule {
|
||||
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(profile);
|
||||
await AppDatabase.setActiveAccount(profile.id);
|
||||
logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}');
|
||||
|
||||
await _saveSyncState(data, serverTime, profile.id);
|
||||
await ContactsModule.syncFromLoginPayload(data, profile.id);
|
||||
await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id);
|
||||
|
||||
final config = data['config'];
|
||||
if (config is Map) {
|
||||
await FoldersModule.applyFromLoginConfig(
|
||||
profile.id,
|
||||
config.cast<dynamic, dynamic>(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await FoldersModule.syncFromServer(_api, profile.id);
|
||||
} catch (e) {
|
||||
logger.w('Папки чатов: $e');
|
||||
}
|
||||
|
||||
return LoginResult(
|
||||
profile: profile,
|
||||
updatedToken: updatedToken,
|
||||
serverTime: serverTime,
|
||||
raw: data,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -401,13 +981,15 @@ class AccountModule {
|
||||
) async {
|
||||
_ensureOnline();
|
||||
|
||||
final normalizedPhone = _normalizeAuthPhone(phone);
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'phone': phone,
|
||||
'phone': normalizedPhone,
|
||||
'type': type.value,
|
||||
'language': language,
|
||||
};
|
||||
|
||||
logger.i('Запрос OTP-кода: phone=$phone type=${type.value}');
|
||||
logger.i('Запрос OTP-кода: phone=$normalizedPhone type=${type.value}');
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.authRequest, payload);
|
||||
|
||||
@@ -415,7 +997,9 @@ class AccountModule {
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('requestCode: неожиданный тип payload: ${data.runtimeType}');
|
||||
throw Exception(
|
||||
'requestCode: неожиданный тип payload: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
final token = data['token'];
|
||||
@@ -437,10 +1021,13 @@ class AccountModule {
|
||||
|
||||
void _checkPacketError(Packet packet, String method) {
|
||||
if (packet.isError) {
|
||||
final errMsg = packet.payload is Map
|
||||
? (packet.payload as Map)['message'] ?? packet.payload.toString()
|
||||
: packet.payload?.toString() ?? 'unknown error';
|
||||
throw Exception('$method: ошибка от сервера — $errMsg');
|
||||
final payload = packet.payload;
|
||||
if (payload is Map &&
|
||||
(payload['message'] == 'FAIL_LOGIN_TOKEN' ||
|
||||
payload['message'] == 'FAIL_WRONG_PASSWORD')) {
|
||||
throw SessionExpiredException(messageFromErrorPayload(payload));
|
||||
}
|
||||
throw PacketError(messageFromErrorPayload(payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Backend module for parsing calls from Komet platform
|
||||
import 'contacts.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
|
||||
enum CallStatus { missed, canceled, outgoing, incoming }
|
||||
|
||||
class CallLogEntry {
|
||||
final String id;
|
||||
final int accountId;
|
||||
final int peerId;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
final CallStatus status;
|
||||
final int time;
|
||||
final int count;
|
||||
|
||||
const CallLogEntry({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.peerId,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
required this.status,
|
||||
required this.time,
|
||||
this.count = 1,
|
||||
});
|
||||
}
|
||||
|
||||
class CallsModule {
|
||||
final Api _api;
|
||||
|
||||
CallsModule(this._api);
|
||||
|
||||
/// Fetch call history from opcode 79
|
||||
Future<List<CallLogEntry>> fetchHistory(
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
) async {
|
||||
final response = await _api.sendRequest(Opcode.videoChatHistory, {});
|
||||
if (!response.isOk || response.payload is! Map) return [];
|
||||
|
||||
final payload = response.payload as Map<dynamic, dynamic>;
|
||||
return parseHistoryPayload(payload, accountId, currentUserId);
|
||||
}
|
||||
|
||||
/// Парсинг истории звонков (opcode 79: videoChatHistory)
|
||||
static Future<List<CallLogEntry>> parseHistoryPayload(
|
||||
Map<dynamic, dynamic> payload,
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
) async {
|
||||
final history = payload['history'];
|
||||
if (history is! List || history.isEmpty) return [];
|
||||
|
||||
final recentContacts = await ContactsModule.getContacts(accountId);
|
||||
final contactsMap = {for (final c in recentContacts) c.id: c};
|
||||
|
||||
final List<CallLogEntry> extractedCalls = [];
|
||||
|
||||
for (final item in history.whereType<Map>()) {
|
||||
final msg = item['message'];
|
||||
if (msg is! Map) continue;
|
||||
|
||||
final attaches = msg['attaches'];
|
||||
if (attaches is! List || attaches.isEmpty) continue;
|
||||
|
||||
final callAttach = attaches.firstWhere(
|
||||
(a) => a is Map && a['_type'] == 'CALL',
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (callAttach == null) continue;
|
||||
|
||||
final senderId = (msg['sender'] as int?) ?? 0;
|
||||
final isOutgoing = senderId == currentUserId;
|
||||
|
||||
int peerId = 0;
|
||||
if (isOutgoing) {
|
||||
final contactIds = callAttach['contactIds'];
|
||||
if (contactIds is List && contactIds.isNotEmpty) {
|
||||
peerId = (contactIds.first as int?) ?? 0;
|
||||
}
|
||||
} else {
|
||||
peerId = senderId;
|
||||
}
|
||||
|
||||
final contact = contactsMap[peerId];
|
||||
final status = _parseCallStatus(callAttach, isOutgoing);
|
||||
final time = (msg['time'] as int?) ?? 0;
|
||||
final msgId =
|
||||
msg['id']?.toString() ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
final name = contact?.firstName != null
|
||||
? '${contact!.firstName} ${contact.lastName ?? ''}'.trim()
|
||||
: 'Неизвестный';
|
||||
|
||||
extractedCalls.add(
|
||||
CallLogEntry(
|
||||
id: msgId,
|
||||
accountId: accountId,
|
||||
peerId: peerId,
|
||||
name: name,
|
||||
avatarUrl: contact?.baseUrl,
|
||||
status: status,
|
||||
time: time,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return extractedCalls;
|
||||
}
|
||||
|
||||
static CallStatus _parseCallStatus(
|
||||
Map<dynamic, dynamic> callAttach,
|
||||
bool isOutgoing,
|
||||
) {
|
||||
final hangupType = callAttach['hangupType'];
|
||||
final duration = (callAttach['duration'] as int?) ?? 0;
|
||||
|
||||
if (isOutgoing) {
|
||||
if (hangupType == 'CANCELED' || duration == 0) return CallStatus.canceled;
|
||||
return CallStatus.outgoing;
|
||||
} else {
|
||||
if (hangupType == 'CANCELED' ||
|
||||
hangupType == 'REJECTED' ||
|
||||
hangupType == 'MISSED' ||
|
||||
duration == 0) {
|
||||
return CallStatus.missed;
|
||||
}
|
||||
return CallStatus.incoming;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+187
-98
@@ -1,4 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
|
||||
class CachedChat {
|
||||
final int id;
|
||||
@@ -13,6 +16,11 @@ class CachedChat {
|
||||
final int unreadCount;
|
||||
final int lastEventTime;
|
||||
final int cachedAt;
|
||||
final int? favIndex;
|
||||
final int dontDisturbUntil;
|
||||
final bool isOnline;
|
||||
final int seenTime;
|
||||
final Map<int, int> participants;
|
||||
|
||||
const CachedChat({
|
||||
required this.id,
|
||||
@@ -27,37 +35,53 @@ class CachedChat {
|
||||
required this.unreadCount,
|
||||
required this.lastEventTime,
|
||||
required this.cachedAt,
|
||||
this.favIndex,
|
||||
required this.dontDisturbUntil,
|
||||
required this.isOnline,
|
||||
required this.seenTime,
|
||||
required this.participants,
|
||||
});
|
||||
|
||||
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
|
||||
id: row['id'] as int,
|
||||
accountId: row['account_id'] as int,
|
||||
type: row['type'] as String,
|
||||
title: row['title'] as String?,
|
||||
iconUrl: row['icon_url'] as String?,
|
||||
lastMsgId: row['last_msg_id'] as int?,
|
||||
lastMsgTime: row['last_msg_time'] as int?,
|
||||
lastMsgText: row['last_msg_text'] as String?,
|
||||
lastMsgSenderId: row['last_msg_sender'] as int?,
|
||||
unreadCount: row['unread_count'] as int,
|
||||
lastEventTime: row['last_event_time'] as int,
|
||||
cachedAt: row['cached_at'] as int,
|
||||
);
|
||||
id: row['id'] as int,
|
||||
accountId: row['account_id'] as int,
|
||||
type: row['type'] as String,
|
||||
title: row['title'] as String?,
|
||||
iconUrl: row['icon_url'] as String?,
|
||||
lastMsgId: row['last_msg_id'] as int?,
|
||||
lastMsgTime: row['last_msg_time'] as int?,
|
||||
lastMsgText: row['last_msg_text'] as String?,
|
||||
lastMsgSenderId: row['last_msg_sender'] as int?,
|
||||
unreadCount: row['unread_count'] as int,
|
||||
lastEventTime: row['last_event_time'] as int,
|
||||
cachedAt: row['cached_at'] as int,
|
||||
favIndex: row['fav_index'] as int?,
|
||||
dontDisturbUntil: row['dont_disturb_until'] as int,
|
||||
isOnline: (row['is_online'] as int) == 1,
|
||||
seenTime: row['seen_time'] as int,
|
||||
// watafuc
|
||||
participants: Map<String, int>.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v))
|
||||
);
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
'type': type,
|
||||
'title': title,
|
||||
'icon_url': iconUrl,
|
||||
'last_msg_id': lastMsgId,
|
||||
'last_msg_time': lastMsgTime,
|
||||
'last_msg_text': lastMsgText,
|
||||
'last_msg_sender': lastMsgSenderId,
|
||||
'unread_count': unreadCount,
|
||||
'last_event_time': lastEventTime,
|
||||
'cached_at': cachedAt,
|
||||
};
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
'type': type,
|
||||
'title': title,
|
||||
'icon_url': iconUrl,
|
||||
'last_msg_id': lastMsgId,
|
||||
'last_msg_time': lastMsgTime,
|
||||
'last_msg_text': lastMsgText,
|
||||
'last_msg_sender': lastMsgSenderId,
|
||||
'unread_count': unreadCount,
|
||||
'last_event_time': lastEventTime,
|
||||
'cached_at': cachedAt,
|
||||
'fav_index': favIndex,
|
||||
'dont_disturb_until': dontDisturbUntil,
|
||||
'is_online': isOnline ? 1 : 0,
|
||||
'seen_time': seenTime,
|
||||
'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v)))
|
||||
};
|
||||
}
|
||||
|
||||
class ChatsModule {
|
||||
@@ -71,46 +95,77 @@ class ChatsModule {
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
) async {
|
||||
final chats = data['chats'];
|
||||
if (chats is! List || chats.isEmpty) return;
|
||||
try {
|
||||
final chats = data['chats'];
|
||||
if (chats is! List || chats.isEmpty) return;
|
||||
|
||||
final contactsMap = _buildContactsMap(data['contacts']);
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
final contactsMap = _buildContactsMap(data['contacts']);
|
||||
// Config contains mute setup and fav indexes: config -> chats -> id
|
||||
final configMap = data['config'] is Map ? data['config'] as Map : {};
|
||||
final chatsConfig = configMap['chats'] is Map
|
||||
? configMap['chats'] as Map
|
||||
: {};
|
||||
// Presence for online statuses
|
||||
final presenceMap = data['presence'] is Map ? data['presence'] as Map : {};
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final existingRows = await AppDatabase.loadChats(accountId);
|
||||
final existing = {
|
||||
for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row),
|
||||
};
|
||||
final existingRows = await AppDatabase.loadChats(accountId);
|
||||
final existing = {
|
||||
for (final row in existingRows)
|
||||
row['id'] as int: CachedChat.fromDbRow(row),
|
||||
};
|
||||
|
||||
final rows = chats
|
||||
.whereType<Map>()
|
||||
.map((c) => _parseChat(
|
||||
final rows = chats
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(c) => _parseChat(
|
||||
c.cast<dynamic, dynamic>(),
|
||||
accountId,
|
||||
currentUserId,
|
||||
contactsMap,
|
||||
chatsConfig,
|
||||
presenceMap,
|
||||
existing,
|
||||
cachedAt,
|
||||
))
|
||||
.whereType<CachedChat>()
|
||||
.map((c) => c.toDbRow())
|
||||
.toList();
|
||||
),
|
||||
)
|
||||
.whereType<CachedChat>()
|
||||
.map((c) => c.toDbRow())
|
||||
.toList();
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
await AppDatabase.saveChats(rows);
|
||||
if (rows.isNotEmpty) {
|
||||
await AppDatabase.saveChats(rows);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при синке: $e");
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CachedChat>> getChats(int accountId) async {
|
||||
final rows = await AppDatabase.loadChats(accountId);
|
||||
return rows.map(CachedChat.fromDbRow).toList();
|
||||
try {
|
||||
final rows = await AppDatabase.loadChats(accountId);
|
||||
|
||||
return rows.map(CachedChat.fromDbRow).toList();
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при получении чатов: $e");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
static Future<List<CachedChat>> getChat(int accountId, int chatId) async {
|
||||
try {
|
||||
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
|
||||
return rows.map(CachedChat.fromDbRow).toList();
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при получении чата: $e");
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> clearCache(int accountId) =>
|
||||
AppDatabase.clearChatsCache(accountId);
|
||||
|
||||
// internal
|
||||
|
||||
static Map<int, Map<dynamic, dynamic>> _buildContactsMap(dynamic contacts) {
|
||||
if (contacts is! List) return {};
|
||||
final result = <int, Map<dynamic, dynamic>>{};
|
||||
@@ -126,61 +181,93 @@ class ChatsModule {
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
Map<int, Map<dynamic, dynamic>> contactsMap,
|
||||
Map<dynamic, dynamic> chatsConfig,
|
||||
Map<dynamic, dynamic> presenceMap,
|
||||
Map<int, CachedChat> existing,
|
||||
int cachedAt,
|
||||
) {
|
||||
final id = chat['id'];
|
||||
if (id is! int) return null;
|
||||
try {
|
||||
final id = chat['id'];
|
||||
if (id is! int) return null;
|
||||
|
||||
final type = (chat['type'] as String?) ?? 'DIALOG';
|
||||
final type = (chat['type'] as String?) ?? 'DIALOG';
|
||||
int? otherId;
|
||||
|
||||
String? title;
|
||||
String? iconUrl;
|
||||
String? title;
|
||||
String? iconUrl;
|
||||
|
||||
if (type == 'DIALOG') {
|
||||
final otherId = _otherParticipantId(chat['participants'], currentUserId);
|
||||
final contact = otherId != null ? contactsMap[otherId] : null;
|
||||
if (type == 'DIALOG') {
|
||||
otherId = _otherParticipantId(chat['participants'], currentUserId);
|
||||
final contact = otherId != null ? contactsMap[otherId] : null;
|
||||
|
||||
if (contact != null) {
|
||||
title = _nameFromContact(contact);
|
||||
iconUrl = contact['baseUrl'] as String?;
|
||||
} else {
|
||||
// Warm start: контакты не пришли — берём из кэша
|
||||
title = existing[id]?.title;
|
||||
iconUrl = existing[id]?.iconUrl;
|
||||
}
|
||||
} else {
|
||||
title = chat['title'] as String?;
|
||||
iconUrl = chat['baseIconUrl'] as String?;
|
||||
if (contact != null) {
|
||||
title = _nameFromContact(contact);
|
||||
iconUrl = contact['baseUrl'] as String?;
|
||||
} else {
|
||||
title = existing[id]?.title;
|
||||
iconUrl = existing[id]?.iconUrl;
|
||||
}
|
||||
} else {
|
||||
title = chat['title'] as String?;
|
||||
iconUrl = chat['baseIconUrl'] as String?;
|
||||
}
|
||||
|
||||
final lastMsg = chat['lastMessage'];
|
||||
int? lastMsgId;
|
||||
int? lastMsgTime;
|
||||
String? lastMsgText;
|
||||
int? lastMsgSenderId;
|
||||
|
||||
if (lastMsg is Map) {
|
||||
lastMsgId = lastMsg['id'] as int?;
|
||||
lastMsgTime = lastMsg['time'] as int?;
|
||||
lastMsgText = lastMsg['text'] as String?;
|
||||
lastMsgSenderId = lastMsg['sender'] as int?;
|
||||
}
|
||||
|
||||
final config = chatsConfig[id.toString()] ?? chatsConfig[id];
|
||||
int? favIndex;
|
||||
int dontDisturbUntil = 0;
|
||||
if (config is Map) {
|
||||
favIndex = config['favIndex'] as int?;
|
||||
dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
int seenTime = 0;
|
||||
bool isOnline = false;
|
||||
if (type == 'DIALOG' && otherId != null) {
|
||||
final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId];
|
||||
if (presence is Map) {
|
||||
seenTime = (presence['seen'] as int?) ?? 0;
|
||||
isOnline = (presence['status'] as int?) == 1;
|
||||
}
|
||||
}
|
||||
Map<int, int> participants = Map<int, int>.from(chat['participants']);
|
||||
|
||||
return CachedChat(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
type: type,
|
||||
title: title,
|
||||
iconUrl: iconUrl,
|
||||
lastMsgId: lastMsgId,
|
||||
lastMsgTime: lastMsgTime,
|
||||
lastMsgText: lastMsgText,
|
||||
lastMsgSenderId: lastMsgSenderId,
|
||||
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
||||
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
||||
cachedAt: cachedAt,
|
||||
favIndex: favIndex,
|
||||
dontDisturbUntil: dontDisturbUntil,
|
||||
isOnline: isOnline,
|
||||
seenTime: seenTime,
|
||||
participants: participants
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при парсинге чата: $e");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
final lastMsg = chat['lastMessage'];
|
||||
int? lastMsgId;
|
||||
int? lastMsgTime;
|
||||
String? lastMsgText;
|
||||
int? lastMsgSenderId;
|
||||
|
||||
if (lastMsg is Map) {
|
||||
lastMsgId = lastMsg['id'] as int?;
|
||||
lastMsgTime = lastMsg['time'] as int?;
|
||||
lastMsgText = lastMsg['text'] as String?;
|
||||
lastMsgSenderId = lastMsg['sender'] as int?;
|
||||
}
|
||||
|
||||
return CachedChat(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
type: type,
|
||||
title: title,
|
||||
iconUrl: iconUrl,
|
||||
lastMsgId: lastMsgId,
|
||||
lastMsgTime: lastMsgTime,
|
||||
lastMsgText: lastMsgText,
|
||||
lastMsgSenderId: lastMsgSenderId,
|
||||
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
||||
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
||||
cachedAt: cachedAt,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _otherParticipantId(dynamic participants, int currentUserId) {
|
||||
@@ -195,10 +282,12 @@ class ChatsModule {
|
||||
static String? _nameFromContact(Map<dynamic, dynamic> contact) {
|
||||
final names = contact['names'];
|
||||
if (names is! List || names.isEmpty) return null;
|
||||
final name = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.first,
|
||||
) as Map;
|
||||
final name =
|
||||
names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.first,
|
||||
)
|
||||
as Map;
|
||||
return name['name'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import '../../core/storage/app_database.dart';
|
||||
|
||||
class CachedContact {
|
||||
final int id;
|
||||
final int accountId;
|
||||
final String firstName;
|
||||
final String? lastName;
|
||||
final int phone;
|
||||
final int? photoId;
|
||||
final String? baseUrl;
|
||||
final String? baseRawUrl;
|
||||
final int updateTime;
|
||||
|
||||
const CachedContact({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.firstName,
|
||||
this.lastName,
|
||||
required this.phone,
|
||||
this.photoId,
|
||||
this.baseUrl,
|
||||
this.baseRawUrl,
|
||||
required this.updateTime,
|
||||
});
|
||||
|
||||
factory CachedContact.fromDbRow(Map<String, dynamic> row) => CachedContact(
|
||||
id: row['id'] as int,
|
||||
accountId: row['account_id'] as int,
|
||||
firstName: row['first_name'] as String,
|
||||
lastName: row['last_name'] as String?,
|
||||
phone: row['phone'] as int,
|
||||
photoId: row['photo_id'] as int?,
|
||||
baseUrl: row['base_url'] as String?,
|
||||
baseRawUrl: row['base_raw_url'] as String?,
|
||||
updateTime: row['update_time'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
class ContactsModule {
|
||||
static Future<void> syncFromLoginPayload(
|
||||
Map<dynamic, dynamic> data,
|
||||
int accountId,
|
||||
) async {
|
||||
final contacts = data['contacts'];
|
||||
if (contacts is! List || contacts.isEmpty) return;
|
||||
|
||||
final rows = contacts
|
||||
.whereType<Map>()
|
||||
.map((c) => _parseContact(c.cast<dynamic, dynamic>(), accountId))
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
await AppDatabase.saveContacts(rows);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CachedContact>> getContacts(int accountId) async {
|
||||
final rows = await AppDatabase.loadContacts(accountId);
|
||||
return rows.map(CachedContact.fromDbRow).toList();
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _parseContact(
|
||||
Map<dynamic, dynamic> contact,
|
||||
int accountId,
|
||||
) {
|
||||
final id = contact['id'];
|
||||
if (id is! int) return null;
|
||||
|
||||
String firstName = '';
|
||||
String? lastName;
|
||||
|
||||
final names = contact['names'];
|
||||
if (names is List && names.isNotEmpty) {
|
||||
final name =
|
||||
names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.first,
|
||||
)
|
||||
as Map;
|
||||
firstName = (name['firstName'] as String?) ?? '';
|
||||
lastName = name['lastName'] as String?;
|
||||
}
|
||||
|
||||
return {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'phone': (contact['phone'] as int?) ?? 0,
|
||||
'photo_id': contact['photoId'] as int?,
|
||||
'base_url': contact['baseUrl'] as String?,
|
||||
'base_raw_url': contact['baseRawUrl'] as String?,
|
||||
'update_time': (contact['updateTime'] as int?) ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../api.dart';
|
||||
import '../models/chat_folder.dart';
|
||||
import 'chats.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
|
||||
class FoldersModule {
|
||||
static const _syncKey = 'chat_folders_snapshot';
|
||||
static const _listReadyKey = 'chat_folders_list_ready';
|
||||
|
||||
static Future<void> markFoldersListReady(int accountId) async {
|
||||
await AppDatabase.setSyncValue(accountId, _listReadyKey, '1');
|
||||
}
|
||||
|
||||
static Future<bool> hasReceivedFoldersList(int accountId) async {
|
||||
final ready = await AppDatabase.getSyncValue(accountId, _listReadyKey);
|
||||
if (ready == '1') return true;
|
||||
final snap = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||
return snap != null && snap.isNotEmpty;
|
||||
}
|
||||
|
||||
static bool isAllChatsFolder(ChatFolder f) {
|
||||
if (f.id == 'all.chat.folder') return true;
|
||||
final t = f.title.trim().toLowerCase();
|
||||
return t == 'все' ||
|
||||
t == 'все чаты' ||
|
||||
t == 'all' ||
|
||||
t == 'all chats';
|
||||
}
|
||||
|
||||
static String? preferredInitialFolderId(List<ChatFolder> folders) {
|
||||
if (folders.isEmpty) return null;
|
||||
for (final f in folders) {
|
||||
if (isAllChatsFolder(f)) return f.id;
|
||||
}
|
||||
return folders.first.id;
|
||||
}
|
||||
|
||||
static void sortFoldersInPlace(
|
||||
List<ChatFolder> folders,
|
||||
List<dynamic>? foldersOrder,
|
||||
) {
|
||||
if (foldersOrder == null || foldersOrder.isEmpty) return;
|
||||
final orderedIds = foldersOrder.map((id) => id.toString()).toList();
|
||||
folders.sort((a, b) {
|
||||
final aIndex = orderedIds.indexOf(a.id);
|
||||
final bIndex = orderedIds.indexOf(b.id);
|
||||
if (aIndex == -1 && bIndex == -1) return 0;
|
||||
if (aIndex == -1) return 1;
|
||||
if (bIndex == -1) return -1;
|
||||
return aIndex.compareTo(bIndex);
|
||||
});
|
||||
}
|
||||
|
||||
static bool chatMatchesFolder(CachedChat chat, ChatFolder folder) {
|
||||
if (folder.include != null && folder.include!.isNotEmpty) {
|
||||
return folder.include!.contains(chat.id);
|
||||
}
|
||||
if (folder.filters.isEmpty) return false;
|
||||
|
||||
final hasContact = folder.filters.any(
|
||||
(f) => f == 9 || f == '9' || f == 'CONTACT',
|
||||
);
|
||||
final hasNotContact = folder.filters.any(
|
||||
(f) => f == 8 || f == '8' || f == 'NOT_CONTACT',
|
||||
);
|
||||
|
||||
if (hasContact && hasNotContact) {
|
||||
if (chat.type != 'DIALOG') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
for (final filter in folder.filters) {
|
||||
if (filter == 0 || filter == '0' || filter == 'UNREAD') {
|
||||
if (chat.unreadCount > 0) return true;
|
||||
} else if (filter == 9 || filter == '9' || filter == 'CONTACT') {
|
||||
if (chat.type == 'DIALOG') return true;
|
||||
} else if (filter == 8 || filter == '8' || filter == 'NOT_CONTACT') {
|
||||
if (chat.type == 'CHAT' || chat.type == 'CHANNEL') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<List<ChatFolder>> loadFolders(int accountId) async {
|
||||
final raw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final folders = (map['folders'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
})
|
||||
.toList() ??
|
||||
[];
|
||||
final order = map['foldersOrder'] as List<dynamic>?;
|
||||
sortFoldersInPlace(folders, order);
|
||||
return folders;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _persist(
|
||||
int accountId,
|
||||
List<ChatFolder> folders,
|
||||
List<dynamic>? order,
|
||||
) async {
|
||||
await AppDatabase.setSyncValue(
|
||||
accountId,
|
||||
_syncKey,
|
||||
jsonEncode({
|
||||
'folders': folders.map((f) => f.toJson()).toList(),
|
||||
'foldersOrder': order,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> applyPayload(
|
||||
int accountId,
|
||||
Map<dynamic, dynamic> payload,
|
||||
) async {
|
||||
final foldersJson = payload['folders'] as List<dynamic>?;
|
||||
final order = payload['foldersOrder'] as List<dynamic>?;
|
||||
if (foldersJson == null && order == null) return;
|
||||
|
||||
List<ChatFolder> folders;
|
||||
if (foldersJson != null) {
|
||||
folders = foldersJson
|
||||
.map((json) {
|
||||
try {
|
||||
final m = json is Map<String, dynamic>
|
||||
? json
|
||||
: Map<String, dynamic>.from(json as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
} else {
|
||||
folders = await loadFolders(accountId);
|
||||
}
|
||||
sortFoldersInPlace(folders, order);
|
||||
await _persist(accountId, folders, order);
|
||||
}
|
||||
|
||||
static Future<void> applyFromLoginConfig(
|
||||
int accountId,
|
||||
Map<dynamic, dynamic> config,
|
||||
) async {
|
||||
final chatFolders = config['chatFolders'];
|
||||
if (chatFolders is! Map) return;
|
||||
final foldersJson = chatFolders['FOLDERS'] as List<dynamic>?;
|
||||
if (foldersJson == null) return;
|
||||
final order = chatFolders['foldersOrder'] as List<dynamic>?;
|
||||
final folders = foldersJson
|
||||
.map((json) {
|
||||
try {
|
||||
final m = json is Map<String, dynamic>
|
||||
? json
|
||||
: Map<String, dynamic>.from(json as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
sortFoldersInPlace(folders, order);
|
||||
await _persist(accountId, folders, order);
|
||||
await markFoldersListReady(accountId);
|
||||
}
|
||||
|
||||
static Future<void> syncFromServer(Api api, int accountId) async {
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.foldersGet, {
|
||||
'folderSync': 0,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
final data = packet.payload;
|
||||
if (data is Map) {
|
||||
await applyPayload(accountId, data.cast<dynamic, dynamic>());
|
||||
}
|
||||
} finally {
|
||||
await markFoldersListReady(accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../models/attachment.dart';
|
||||
|
||||
class ContactCache {
|
||||
static final Map<int, String> _nameCache = {};
|
||||
static final Map<int, String> _avatarCache = {};
|
||||
|
||||
static void put(int id, String name) {
|
||||
_nameCache[id] = name;
|
||||
}
|
||||
|
||||
static void putAvatar(int id, String? baseUrl) {
|
||||
if (baseUrl != null) {
|
||||
_avatarCache[id] = baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
static String? get(int id) => _nameCache[id];
|
||||
static String? getAvatar(int id) => _avatarCache[id];
|
||||
}
|
||||
|
||||
class CachedMessage {
|
||||
final String id;
|
||||
final int accountId;
|
||||
final int chatId;
|
||||
final int senderId;
|
||||
final String? text;
|
||||
final int time;
|
||||
final String? status;
|
||||
final Map<String, dynamic>? payload;
|
||||
final List<MessageAttachment>? attachments;
|
||||
|
||||
const CachedMessage({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.chatId,
|
||||
required this.senderId,
|
||||
this.text,
|
||||
required this.time,
|
||||
this.status,
|
||||
this.payload,
|
||||
this.attachments,
|
||||
});
|
||||
|
||||
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
|
||||
Map<String, dynamic>? payload;
|
||||
final payloadRaw = row['payload'];
|
||||
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
||||
try {
|
||||
payload = jsonDecode(payloadRaw) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
List<MessageAttachment>? attachments;
|
||||
if (payload != null) {
|
||||
final linkType = payload['link']?['type'] as String?;
|
||||
if (linkType == 'FORWARD') {
|
||||
attachments = [ForwardedMessageAttachment.fromMap(payload)];
|
||||
} else {
|
||||
final attaches = payload['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.map(
|
||||
(a) => MessageAttachment.fromMap(
|
||||
Map<String, dynamic>.from(a as Map),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CachedMessage(
|
||||
id: row['id'] as String,
|
||||
accountId: row['account_id'] as int,
|
||||
chatId: row['chat_id'] as int,
|
||||
senderId: row['sender_id'] as int,
|
||||
text: row['text'] as String?,
|
||||
time: row['time'] as int,
|
||||
status: row['status'] as String?,
|
||||
payload: payload,
|
||||
attachments: attachments,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
'chat_id': chatId,
|
||||
'sender_id': senderId,
|
||||
'text': text,
|
||||
'time': time,
|
||||
'status': status,
|
||||
'payload': payload != null ? jsonEncode(payload) : null,
|
||||
};
|
||||
}
|
||||
|
||||
class MessagesModule {
|
||||
final Api _api;
|
||||
|
||||
MessagesModule(this._api);
|
||||
|
||||
/// Загружает историю сообщений для указанного чата.
|
||||
///
|
||||
/// [fromTime] — опционально, время от которого грузить (миллисекунды).
|
||||
/// Если не указано, грузит самые свежие.
|
||||
/// [count] — количество сообщений.
|
||||
Future<List<CachedMessage>> fetchHistory(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
int? fromTime,
|
||||
int count = 50,
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'from':
|
||||
fromTime ??
|
||||
(DateTime.now().millisecondsSinceEpoch +
|
||||
86400000), // +1 день для запаса
|
||||
'forward': 0,
|
||||
'backward': count,
|
||||
'getMessages': true,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.chatHistory, payload);
|
||||
|
||||
if (!response.isOk) return [];
|
||||
|
||||
final data = response.payload;
|
||||
if (data is! Map) return [];
|
||||
|
||||
final messagesData = data['messages'];
|
||||
if (messagesData is! List) return [];
|
||||
|
||||
final List<CachedMessage> results = [];
|
||||
final List<Map<String, dynamic>> rows = [];
|
||||
|
||||
for (var i = 0; i < messagesData.length; i++) {
|
||||
final m = messagesData[i];
|
||||
if (m is! Map) continue;
|
||||
|
||||
final msg = _parseMessage(m.cast<dynamic, dynamic>(), accountId, chatId);
|
||||
if (msg != null) {
|
||||
results.add(msg);
|
||||
rows.add(msg.toDbRow());
|
||||
}
|
||||
|
||||
if (i > 0 && i % 20 == 0) {
|
||||
await Future.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
AppDatabase.saveMessages(rows).ignore();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Загружает сообщения из локальной базы данных.
|
||||
Future<List<CachedMessage>> getLocalHistory(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final rows = await AppDatabase.loadMessages(
|
||||
accountId,
|
||||
chatId,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map(CachedMessage.fromDbRow).toList();
|
||||
}
|
||||
|
||||
CachedMessage? _parseMessage(
|
||||
Map<dynamic, dynamic> m,
|
||||
int accountId,
|
||||
int chatId,
|
||||
) {
|
||||
final id = m['id']?.toString();
|
||||
if (id == null) return null;
|
||||
|
||||
final linkRaw = m['link'];
|
||||
String? linkType;
|
||||
if (linkRaw is Map) {
|
||||
linkType = linkRaw['type'] as String?;
|
||||
}
|
||||
|
||||
List<MessageAttachment>? attachments;
|
||||
if (linkType == 'FORWARD') {
|
||||
final fwdMap = Map<String, dynamic>.from(m.cast());
|
||||
attachments = [ForwardedMessageAttachment.fromMap(fwdMap)];
|
||||
} else {
|
||||
final attaches = m['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
return CachedMessage(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
senderId: (m['sender'] as int?) ?? 0,
|
||||
text: m['text'] as String?,
|
||||
time: (m['time'] as int?) ?? 0,
|
||||
status: m['status'] as String?,
|
||||
payload: Map<String, dynamic>.from(m.cast()),
|
||||
attachments: attachments,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String text, {
|
||||
bool notify = true,
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': {
|
||||
'text': text,
|
||||
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
||||
'elements': [],
|
||||
'attaches': [],
|
||||
},
|
||||
'notify': notify,
|
||||
};
|
||||
|
||||
await _api.sendRequest(Opcode.msgSend, payload);
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': photoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final content = data['content'];
|
||||
if (content is String) {
|
||||
return Uri.parse(content).host.isNotEmpty ? null : null;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getPhotoUrl(String baseUrl, String photoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': photoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': videoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final content = data['content'];
|
||||
if (content is String) {
|
||||
return Uri.parse(content).host.isNotEmpty ? null : null;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getVideoUrl(String baseUrl, String videoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': videoToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': fileToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final content = data['content'];
|
||||
if (content is String) {
|
||||
return Uri.parse(content).host.isNotEmpty ? null : null;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getFileUrl(String baseUrl, String fileToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': fileToken,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> searchContactById(int contactId) async {
|
||||
final cached = ContactCache.get(contactId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.contactInfo, {
|
||||
'contactIds': [contactId],
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final contacts = data['contacts'] as List?;
|
||||
if (contacts != null && contacts.isNotEmpty) {
|
||||
final contact = contacts.first;
|
||||
if (contact is Map) {
|
||||
final names = contact['names'] as List?;
|
||||
if (names != null && names.isNotEmpty) {
|
||||
final name = names.first;
|
||||
if (name is Map) {
|
||||
final firstName = name['firstName'] as String? ?? '';
|
||||
final lastName = name['lastName'] as String?;
|
||||
final fullName = lastName != null
|
||||
? '$firstName $lastName'
|
||||
: firstName;
|
||||
ContactCache.put(contactId, fullName);
|
||||
|
||||
final baseUrl = contact['baseUrl'] as String?;
|
||||
ContactCache.putAvatar(contactId, baseUrl);
|
||||
|
||||
return fullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('searchContactById error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class ServerConfig {
|
||||
static const String host = 'api.oneme.ru';
|
||||
static const int port = 443;
|
||||
static const String defaultHost = 'api.oneme.ru';
|
||||
static const int defaultPort = 443;
|
||||
static const String prefHostKey = 'server_host_override';
|
||||
static const String prefPortKey = 'server_port_override';
|
||||
static const Duration pingInterval = Duration(seconds: 30);
|
||||
static const Duration requestTimeout = Duration(seconds: 30);
|
||||
static const int maxReconnectAttempts = 50;
|
||||
|
||||
static Future<({String host, int port})> loadEndpoint() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final rawHost = prefs.getString(prefHostKey);
|
||||
final rawPort = prefs.getInt(prefPortKey);
|
||||
final host = (rawHost != null && rawHost.trim().isNotEmpty)
|
||||
? rawHost.trim()
|
||||
: defaultHost;
|
||||
var port = defaultPort;
|
||||
if (rawPort != null && rawPort >= 1 && rawPort <= 65535) {
|
||||
port = rawPort;
|
||||
}
|
||||
return (host: host, port: port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ final Map<String, CountryName> countriesByCode = {
|
||||
for (final country in allCountries) country.code: country,
|
||||
};
|
||||
|
||||
List<CountryName> countriesInServerOrder(Iterable<String> codes) {
|
||||
final out = <CountryName>[];
|
||||
for (final raw in codes) {
|
||||
final c = countriesByCode[raw.toUpperCase()];
|
||||
if (c != null) out.add(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Пример использования:
|
||||
/// ```dart
|
||||
/// final country = exampleCountryLookup('RU');
|
||||
@@ -157,7 +166,7 @@ const String _countriesEnJson = '''[
|
||||
{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]},
|
||||
{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]},
|
||||
{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
@@ -333,7 +342,7 @@ const String _countriesRuJson = '''[
|
||||
{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]},
|
||||
{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]},
|
||||
{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum ProxyType { none, socks5, httpConnect }
|
||||
|
||||
class ProxySettings {
|
||||
final ProxyType type;
|
||||
final String host;
|
||||
final int port;
|
||||
final String? username;
|
||||
final String? password;
|
||||
|
||||
const ProxySettings({
|
||||
this.type = ProxyType.none,
|
||||
this.host = '',
|
||||
this.port = 1080,
|
||||
this.username,
|
||||
this.password,
|
||||
});
|
||||
|
||||
bool get isEnabled => type != ProxyType.none && host.isNotEmpty;
|
||||
|
||||
bool get hasCredentials =>
|
||||
username != null &&
|
||||
username!.isNotEmpty &&
|
||||
password != null &&
|
||||
password!.isNotEmpty;
|
||||
}
|
||||
|
||||
abstract class ProxyConfig {
|
||||
static const String _prefType = 'proxy_type';
|
||||
static const String _prefHost = 'proxy_host';
|
||||
static const String _prefPort = 'proxy_port';
|
||||
static const String _prefUsername = 'proxy_username';
|
||||
static const String _prefPassword = 'proxy_password';
|
||||
|
||||
static Future<ProxySettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final typeIndex = prefs.getInt(_prefType) ?? 0;
|
||||
final host = prefs.getString(_prefHost) ?? '';
|
||||
final port = prefs.getInt(_prefPort) ?? 1080;
|
||||
final username = prefs.getString(_prefUsername);
|
||||
final password = prefs.getString(_prefPassword);
|
||||
return ProxySettings(
|
||||
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
|
||||
host: host,
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> save(ProxySettings settings) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_prefType, settings.type.index);
|
||||
await prefs.setString(_prefHost, settings.host);
|
||||
await prefs.setInt(_prefPort, settings.port);
|
||||
if (settings.username != null) {
|
||||
await prefs.setString(_prefUsername, settings.username!);
|
||||
} else {
|
||||
await prefs.remove(_prefUsername);
|
||||
}
|
||||
if (settings.password != null) {
|
||||
await prefs.setString(_prefPassword, settings.password!);
|
||||
} else {
|
||||
await prefs.remove(_prefPassword);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefType);
|
||||
await prefs.remove(_prefHost);
|
||||
await prefs.remove(_prefPort);
|
||||
await prefs.remove(_prefUsername);
|
||||
await prefs.remove(_prefPassword);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
class SpoofData {
|
||||
static const List<String> deviceNames = [
|
||||
'Samsung Galaxy S23',
|
||||
'Xiaomi 13 Pro',
|
||||
'Google Pixel 7',
|
||||
'OnePlus 11',
|
||||
];
|
||||
|
||||
static const List<String> osVersions = ['12', '13', '14'];
|
||||
|
||||
static const List<String> resolutions = [
|
||||
'1080x2400',
|
||||
'1440x3200',
|
||||
'720x1600',
|
||||
];
|
||||
|
||||
static const List<String> deviceIds = [
|
||||
'a1b2c3d4e5f6',
|
||||
'f8e7d6c5b4a3',
|
||||
'9876543210ab',
|
||||
'1234567890cd',
|
||||
];
|
||||
|
||||
static const List<String> architectures = ['arm64-v8a', 'armeabi-v7a'];
|
||||
|
||||
static const String deviceType = 'android';
|
||||
static const String timezone = 'Europe/Moscow';
|
||||
static const String locale = 'ru_RU';
|
||||
static const String appVersion = '26.10.1';
|
||||
static const String buildNumber = '6728';
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
///
|
||||
/// Naming follows the server-side convention.
|
||||
/// Use [Opcode.name] to get a human-readable label for logging.
|
||||
///
|
||||
///
|
||||
/// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения)
|
||||
abstract class Opcode {
|
||||
// ── Session ────────────────────────────────────────────────────────
|
||||
@@ -124,7 +124,7 @@ abstract class Opcode {
|
||||
// ── Sessions ───────────────────────────────────────────────────────
|
||||
static const int sessionsInfo = 96; // Запрос активных сессий
|
||||
static const int sessionsClose = 97; // Закрытие всех сессий
|
||||
static const int phoneBind_request = 98; // Запрос привязки телефона
|
||||
static const int phoneBindRequest = 98; // Запрос привязки телефона
|
||||
static const int phoneBindConfirm = 99; // Подтверждение привязки телефона
|
||||
|
||||
// ── Bots ───────────────────────────────────────────────────────────
|
||||
@@ -297,7 +297,7 @@ abstract class Opcode {
|
||||
audioPlay: 'AUDIO_PLAY',
|
||||
sessionsInfo: 'SESSIONS_INFO',
|
||||
sessionsClose: 'SESSIONS_CLOSE',
|
||||
phoneBind_request: 'PHONE_BIND_REQUEST',
|
||||
phoneBindRequest: 'PHONE_BIND_REQUEST',
|
||||
phoneBindConfirm: 'PHONE_BIND_CONFIRM',
|
||||
chatComplain: 'CHAT_COMPLAIN',
|
||||
msgSendCallback: 'MSG_SEND_CALLBACK',
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:isolate';
|
||||
import 'package:dart_lz4/dart_lz4.dart';
|
||||
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
|
||||
const int headerSize = 10;
|
||||
const int _maxDecompressedSize = 1048576; // 1 MB
|
||||
@@ -53,6 +52,34 @@ class Packet {
|
||||
'Packet(ver=$api cmd=$cmd seq=$seq opcode=$opcode payload=$payload)';
|
||||
}
|
||||
|
||||
class PacketError implements Exception {
|
||||
final String message;
|
||||
const PacketError(this.message);
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class SessionExpiredException extends PacketError {
|
||||
const SessionExpiredException(super.message);
|
||||
}
|
||||
|
||||
String messageFromErrorPayload(dynamic payload) {
|
||||
if (payload is Map) {
|
||||
final msg = payload['message'];
|
||||
if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') {
|
||||
return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.';
|
||||
}
|
||||
for (final key in ['localizedMessage', 'message', 'title']) {
|
||||
final v = payload[key];
|
||||
if (v is String && v.trim().isNotEmpty) return v.trim();
|
||||
}
|
||||
return 'Неизвестная ошибка';
|
||||
}
|
||||
if (payload == null) return 'Неизвестная ошибка';
|
||||
final s = payload.toString();
|
||||
return s.isNotEmpty ? s : 'Неизвестная ошибка';
|
||||
}
|
||||
|
||||
/// Упаковка пакета для отправки на сервер
|
||||
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
||||
final header = ByteData(headerSize);
|
||||
@@ -69,71 +96,73 @@ Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
||||
}
|
||||
|
||||
/// Распаковка пакета от сервера
|
||||
Packet unpackPacket(Uint8List packet) {
|
||||
// Для удобства расшифровки пакета переводим в ByteData
|
||||
ByteData packetData = ByteData.view(
|
||||
packet.buffer,
|
||||
packet.offsetInBytes,
|
||||
packet.lengthInBytes,
|
||||
);
|
||||
Future<Packet> unpackPacket(Uint8List packet) async {
|
||||
return Isolate.run(() {
|
||||
// Для удобства расшифровки пакета переводим в ByteData
|
||||
ByteData packetData = ByteData.view(
|
||||
packet.buffer,
|
||||
packet.offsetInBytes,
|
||||
packet.lengthInBytes,
|
||||
);
|
||||
|
||||
// Объяснение каждой переменной смотри в классе Packet
|
||||
// API версия и cmd представляют из себя 8 битные числа
|
||||
final apiVer = packetData.getUint8(0) & 0xFF;
|
||||
final cmd = packetData.getUint8(1) & 0xFF;
|
||||
|
||||
// API версия и cmd представляют из себя 8 битные числа
|
||||
final apiVer = packetData.getUint8(0) & 0xFF;
|
||||
final cmd = packetData.getUint8(1) & 0xFF;
|
||||
// Sequence и OPCode представляют из себя 16 битные числа
|
||||
final seq = packetData.getUint16(2) & 0xFFFF;
|
||||
final opcode = packetData.getUint16(4) & 0xFFFF;
|
||||
|
||||
// Sequence и OPCode представляют из себя 16 битные числа
|
||||
final seq = packetData.getUint16(2) & 0xFFFF;
|
||||
final opcode = packetData.getUint16(4) & 0xFFFF;
|
||||
// После базовых переменных идет длина пакета, является 32 битным числом
|
||||
final packedLen = packetData.getUint32(6);
|
||||
|
||||
// После базовых переменных идет длина пакета, является 32 битным числом
|
||||
final packedLen = packetData.getUint32(6);
|
||||
// Compression flag показывает, сжат ли payload
|
||||
final compFlag = packedLen >> 24;
|
||||
|
||||
// Compression flag показывает, сжат ли payload
|
||||
final compFlag = packedLen >> 24;
|
||||
|
||||
// Длина payload'а
|
||||
final payloadLength = packedLen & 0xFFFFFF;
|
||||
// Длина payload'а
|
||||
final payloadLength = packedLen & 0xFFFFFF;
|
||||
|
||||
// Байты payload'а, могут быть сжаты LZ4
|
||||
var payloadBytes = packet.buffer.asUint8List(10, payloadLength);
|
||||
// Байты payload'а, могут быть сжаты LZ4
|
||||
var payloadBytes = packet.buffer.asUint8List(10, payloadLength);
|
||||
|
||||
dynamic payload;
|
||||
|
||||
if (payloadBytes.isNotEmpty) {
|
||||
if (compFlag != 0) {
|
||||
try {
|
||||
payloadBytes = lz4Decompress(
|
||||
payloadBytes,
|
||||
decompressedSize: _maxDecompressedSize,
|
||||
);
|
||||
|
||||
} catch (_) {
|
||||
dynamic payload;
|
||||
|
||||
if (payloadBytes.isNotEmpty) {
|
||||
if (compFlag != 0) {
|
||||
try {
|
||||
payloadBytes = _lz4BlockDecompress(payloadBytes, _maxDecompressedSize);
|
||||
} catch (e) {
|
||||
logger.e("LZ4 decompression error: $e", error: e);
|
||||
payloadBytes = lz4Decompress(
|
||||
payloadBytes,
|
||||
decompressedSize: _maxDecompressedSize,
|
||||
);
|
||||
} catch (_) {
|
||||
try {
|
||||
payloadBytes = _lz4BlockDecompress(
|
||||
payloadBytes,
|
||||
_maxDecompressedSize,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception("LZ4 decompression error: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
payload = msgpack.deserialize(payloadBytes);
|
||||
} catch (e) {
|
||||
if (payloadBytes.isNotEmpty) {
|
||||
throw Exception("MsgPack deserialization error: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
payload = msgpack.deserialize(payloadBytes);
|
||||
} catch (e) {
|
||||
if (payloadBytes.isNotEmpty) {
|
||||
logger.e("MsgPack deserialization error: $e", error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Packet(
|
||||
api: apiVer,
|
||||
cmd: cmd,
|
||||
seq: seq,
|
||||
opcode: opcode,
|
||||
payload: payload,
|
||||
);
|
||||
return Packet(
|
||||
api: apiVer,
|
||||
cmd: cmd,
|
||||
seq: seq,
|
||||
opcode: opcode,
|
||||
payload: payload,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// LZ4 block декомпрессия (без frame-заголовка).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:komet/core/utils/logger.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
class ProfileData {
|
||||
@@ -15,6 +15,7 @@ class ProfileData {
|
||||
final String country;
|
||||
final int accountStatus;
|
||||
final int updateTime;
|
||||
final List<int>? profileOptions;
|
||||
|
||||
ProfileData({
|
||||
required this.id,
|
||||
@@ -27,6 +28,7 @@ class ProfileData {
|
||||
required this.country,
|
||||
required this.accountStatus,
|
||||
required this.updateTime,
|
||||
this.profileOptions,
|
||||
});
|
||||
|
||||
factory ProfileData.fromServerMap(Map<dynamic, dynamic> contact) {
|
||||
@@ -35,14 +37,22 @@ class ProfileData {
|
||||
String? lastName;
|
||||
|
||||
if (names is List && names.isNotEmpty) {
|
||||
final name = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.first,
|
||||
) as Map;
|
||||
final name =
|
||||
names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.first,
|
||||
)
|
||||
as Map;
|
||||
firstName = (name['firstName'] as String?) ?? '';
|
||||
lastName = name['lastName'] as String?;
|
||||
}
|
||||
|
||||
final profileOptionsRaw = contact['profileOptions'];
|
||||
List<int>? profileOptions;
|
||||
if (profileOptionsRaw is List) {
|
||||
profileOptions = profileOptionsRaw.map((e) => e as int).toList();
|
||||
}
|
||||
|
||||
return ProfileData(
|
||||
id: contact['id'] as int,
|
||||
firstName: firstName,
|
||||
@@ -54,36 +64,47 @@ class ProfileData {
|
||||
country: (contact['country'] as String?) ?? '',
|
||||
accountStatus: (contact['accountStatus'] as int?) ?? 0,
|
||||
updateTime: (contact['updateTime'] as int?) ?? 0,
|
||||
profileOptions: profileOptions,
|
||||
);
|
||||
}
|
||||
|
||||
factory ProfileData.fromDbRow(Map<String, dynamic> row) {
|
||||
final profileOptionsStr = row['profile_options'] as String?;
|
||||
List<int>? profileOptions;
|
||||
if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) {
|
||||
profileOptions = profileOptionsStr
|
||||
.split(',')
|
||||
.map((e) => int.parse(e.trim()))
|
||||
.toList();
|
||||
}
|
||||
return ProfileData(
|
||||
id: row['id'] as int,
|
||||
firstName: row['first_name'] as String,
|
||||
firstName: (row['first_name'] as String?) ?? '',
|
||||
lastName: row['last_name'] as String?,
|
||||
phone: row['phone'] as int,
|
||||
phone: (row['phone'] as int?) ?? 0,
|
||||
photoId: row['photo_id'] as int?,
|
||||
baseUrl: row['base_url'] as String?,
|
||||
baseRawUrl: row['base_raw_url'] as String?,
|
||||
country: row['country'] as String,
|
||||
accountStatus: row['account_status'] as int,
|
||||
updateTime: row['update_time'] as int,
|
||||
country: (row['country'] as String?) ?? '',
|
||||
accountStatus: (row['account_status'] as int?) ?? 0,
|
||||
updateTime: (row['update_time'] as int?) ?? 0,
|
||||
profileOptions: profileOptions,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'phone': phone,
|
||||
'photo_id': photoId,
|
||||
'base_url': baseUrl,
|
||||
'base_raw_url': baseRawUrl,
|
||||
'country': country,
|
||||
'account_status': accountStatus,
|
||||
'update_time': updateTime,
|
||||
};
|
||||
'id': id,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'phone': phone,
|
||||
'photo_id': photoId,
|
||||
'base_url': baseUrl,
|
||||
'base_raw_url': baseRawUrl,
|
||||
'country': country,
|
||||
'account_status': accountStatus,
|
||||
'update_time': updateTime,
|
||||
'profile_options': profileOptions?.join(','),
|
||||
};
|
||||
}
|
||||
|
||||
abstract class SyncKey {
|
||||
@@ -118,7 +139,7 @@ class AppDatabase {
|
||||
final dbPath = await getDatabasesPath();
|
||||
return openDatabase(
|
||||
join(dbPath, 'komet.db'),
|
||||
version: 3,
|
||||
version: 8,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -132,6 +153,26 @@ class AppDatabase {
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(_chatsCacheSchema);
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
await db.execute(_contactsSchema);
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
await db.execute('DROP TABLE IF EXISTS chats_cache');
|
||||
await db.execute(_chatsCacheSchema);
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
await db.execute(_messagesSchema);
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
await db.execute(
|
||||
'ALTER TABLE profile ADD COLUMN profile_options TEXT',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 8) {
|
||||
await db.execute(
|
||||
'ALTER TABLE chats_cache ADD COLUMN participants TEXT',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -149,13 +190,30 @@ class AppDatabase {
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
account_status INTEGER NOT NULL DEFAULT 0,
|
||||
update_time INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 0
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
profile_options TEXT
|
||||
)
|
||||
''');
|
||||
await db.execute(_syncStateSchema);
|
||||
await db.execute(_chatsCacheSchema);
|
||||
await db.execute(_contactsSchema);
|
||||
await db.execute(_messagesSchema);
|
||||
}
|
||||
|
||||
static const _contactsSchema = '''
|
||||
CREATE TABLE contacts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
phone INTEGER NOT NULL,
|
||||
photo_id INTEGER,
|
||||
base_url TEXT,
|
||||
base_raw_url TEXT,
|
||||
update_time INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''';
|
||||
|
||||
static const _syncStateSchema = '''
|
||||
CREATE TABLE sync_state (
|
||||
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
@@ -179,10 +237,30 @@ class AppDatabase {
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_event_time INTEGER NOT NULL DEFAULT 0,
|
||||
cached_at INTEGER NOT NULL,
|
||||
fav_index INTEGER,
|
||||
dont_disturb_until INTEGER NOT NULL DEFAULT 0,
|
||||
is_online INTEGER NOT NULL DEFAULT 0,
|
||||
seen_time INTEGER NOT NULL DEFAULT 0,
|
||||
participants TEXT NOT NULL DEFAULT "",
|
||||
PRIMARY KEY (id, account_id)
|
||||
)
|
||||
''';
|
||||
|
||||
static const _messagesSchema = '''
|
||||
CREATE TABLE messages (
|
||||
id TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
chat_id INTEGER NOT NULL,
|
||||
sender_id INTEGER NOT NULL,
|
||||
text TEXT,
|
||||
time INTEGER NOT NULL,
|
||||
status TEXT,
|
||||
payload TEXT,
|
||||
PRIMARY KEY (id, account_id),
|
||||
FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE
|
||||
)
|
||||
''';
|
||||
|
||||
static Future<void> saveProfile(ProfileData profile) async {
|
||||
final db = await _instance;
|
||||
await db.insert(
|
||||
@@ -212,11 +290,7 @@ class AppDatabase {
|
||||
|
||||
static Future<ProfileData?> loadActiveProfile() async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'profile',
|
||||
where: 'is_active = 1',
|
||||
limit: 1,
|
||||
);
|
||||
final rows = await db.query('profile', where: 'is_active = 1', limit: 1);
|
||||
if (rows.isEmpty) return null;
|
||||
return ProfileData.fromDbRow(rows.first);
|
||||
}
|
||||
@@ -245,11 +319,11 @@ class AppDatabase {
|
||||
String value,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
await db.insert(
|
||||
'sync_state',
|
||||
{'account_id': accountId, 'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await db.insert('sync_state', {
|
||||
'account_id': accountId,
|
||||
'key': key,
|
||||
'value': value,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
static Future<String?> getSyncValue(int accountId, String key) async {
|
||||
@@ -276,22 +350,64 @@ class AppDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
static Future<void> savePrivacyConfig(
|
||||
int accountId,
|
||||
String jsonConfig,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
await db.insert('sync_state', {
|
||||
'account_id': accountId,
|
||||
'key': 'privacy_config',
|
||||
'value': jsonConfig,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
static Future<String?> getPrivacyConfig(int accountId) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'sync_state',
|
||||
where: 'account_id = ? AND key = ?',
|
||||
whereArgs: [accountId, 'privacy_config'],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['value'] as String;
|
||||
}
|
||||
|
||||
static Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
}
|
||||
|
||||
// Chats cache
|
||||
// Chats cache
|
||||
|
||||
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
||||
final db = await _instance;
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert('chats_cache', row, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
try {
|
||||
final db = await _instance;
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'chats_cache',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при сохранении чата: $e");
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadChat(int accountId, int chatId) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'chats_cache',
|
||||
where: 'account_id = ? AND id = ?',
|
||||
whereArgs: [accountId, chatId],
|
||||
orderBy: 'last_event_time DESC',
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadChats(int accountId) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
@@ -304,6 +420,73 @@ class AppDatabase {
|
||||
|
||||
static Future<void> clearChatsCache(int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.delete('chats_cache', where: 'account_id = ?', whereArgs: [accountId]);
|
||||
await db.delete(
|
||||
'chats_cache',
|
||||
where: 'account_id = ?',
|
||||
whereArgs: [accountId],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> saveContacts(List<Map<String, dynamic>> rows) async {
|
||||
final db = await _instance;
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'contacts',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadContacts(int accountId) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'contacts',
|
||||
where: 'account_id = ?',
|
||||
whereArgs: [accountId],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> saveMessages(List<Map<String, dynamic>> rows) async {
|
||||
final db = await _instance;
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'messages',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadMessages(
|
||||
int accountId,
|
||||
int chatId, {
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'messages',
|
||||
where: 'account_id = ? AND chat_id = ?',
|
||||
whereArgs: [accountId, chatId],
|
||||
orderBy: 'time DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> clearMessages(int accountId, int chatId) async {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
'messages',
|
||||
where: 'account_id = ? AND chat_id = ?',
|
||||
whereArgs: [accountId, chatId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SpoofingService {
|
||||
static const String hardcodedAppVersion = '26.8.1';
|
||||
static const int hardcodedBuildNumber = 6606;
|
||||
|
||||
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final isEnabled = prefs.getBool('spoofing_enabled') ?? false;
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return {
|
||||
'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': hardcodedAppVersion,
|
||||
'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a',
|
||||
'build_number': hardcodedBuildNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,32 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class TokenStorage {
|
||||
static const _tokenPrefix = 'auth_token_';
|
||||
static const _activeAccountKey = 'active_account_id';
|
||||
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
static Future<void> saveToken(String token, int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('$_tokenPrefix$accountId', token);
|
||||
}
|
||||
|
||||
static Future<void> saveToken(String token, int accountId) =>
|
||||
_storage.write(key: '$_tokenPrefix$accountId', value: token);
|
||||
static Future<String?> readToken(int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('$_tokenPrefix$accountId');
|
||||
}
|
||||
|
||||
static Future<String?> readToken(int accountId) =>
|
||||
_storage.read(key: '$_tokenPrefix$accountId');
|
||||
static Future<void> deleteToken(int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('$_tokenPrefix$accountId');
|
||||
}
|
||||
|
||||
static Future<void> deleteToken(int accountId) =>
|
||||
_storage.delete(key: '$_tokenPrefix$accountId');
|
||||
|
||||
static Future<void> setActiveAccount(int accountId) =>
|
||||
_storage.write(key: _activeAccountKey, value: accountId.toString());
|
||||
static Future<void> setActiveAccount(int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_activeAccountKey, accountId.toString());
|
||||
}
|
||||
|
||||
static Future<int?> getActiveAccountId() async {
|
||||
final val = await _storage.read(key: _activeAccountKey);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(_activeAccountKey);
|
||||
return val != null ? int.tryParse(val) : null;
|
||||
}
|
||||
|
||||
@@ -31,12 +36,12 @@ class TokenStorage {
|
||||
return readToken(id);
|
||||
}
|
||||
|
||||
/// Удаляет токен аккаунта и, если он был активным, сбрасывает активный аккаунт.
|
||||
static Future<void> deleteAccount(int accountId) async {
|
||||
await deleteToken(accountId);
|
||||
final activeId = await getActiveAccountId();
|
||||
if (activeId == accountId) {
|
||||
await _storage.delete(key: _activeAccountKey);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_activeAccountKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../config/proxy_config.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'proxy_connector.dart';
|
||||
|
||||
enum SocketState { disconnected, connecting, connected }
|
||||
|
||||
/// Обёртка над TCP + TLS сокетом.
|
||||
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
||||
class Connection {
|
||||
SecureSocket? _socket;
|
||||
RawSecureSocket? _socket;
|
||||
StreamSubscription<RawSocketEvent>? _subscription;
|
||||
SocketState _state = SocketState.disconnected;
|
||||
|
||||
final _dataController = StreamController<Uint8List>.broadcast();
|
||||
@@ -31,17 +34,39 @@ class Connection {
|
||||
_setState(SocketState.connecting);
|
||||
|
||||
try {
|
||||
final socket = await Socket.connect(host, port);
|
||||
_socket = await SecureSocket.secure(
|
||||
socket,
|
||||
final proxySettings = await ProxyConfig.load();
|
||||
RawSocket rawSocket;
|
||||
|
||||
if (proxySettings.isEnabled) {
|
||||
final connector = ProxyConnector(proxySettings);
|
||||
rawSocket = await connector.connect(host, port);
|
||||
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
||||
} else {
|
||||
rawSocket = await RawSocket.connect(host, port);
|
||||
}
|
||||
|
||||
_socket = await RawSecureSocket.secure(
|
||||
rawSocket,
|
||||
host: host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
|
||||
_setState(SocketState.connected);
|
||||
logger.i('Подключено к $host:$port');
|
||||
|
||||
_socket!.listen(
|
||||
(data) => _dataController.add(Uint8List.fromList(data)),
|
||||
_subscription = _socket!.listen(
|
||||
(event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final data = _socket?.read();
|
||||
if (data != null) {
|
||||
_dataController.add(data);
|
||||
}
|
||||
} else if (event == RawSocketEvent.readClosed ||
|
||||
event == RawSocketEvent.closed) {
|
||||
logger.w('Сокет закрыт сервером');
|
||||
disconnect();
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
logger.e('Ошибка сокета: $error');
|
||||
disconnect();
|
||||
@@ -62,16 +87,18 @@ class Connection {
|
||||
if (_socket == null || !isConnected) {
|
||||
throw StateError('Нельзя писать: сокет не подключён');
|
||||
}
|
||||
_socket!.add(data);
|
||||
_socket!.write(data);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
final socket = _socket;
|
||||
_socket = null;
|
||||
|
||||
if (socket != null) {
|
||||
try {
|
||||
socket.destroy();
|
||||
socket.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,9 @@ class PacketDispatcher {
|
||||
if (packet.cmd == CmdType.ok ||
|
||||
packet.cmd == CmdType.error ||
|
||||
packet.cmd == CmdType.notFound) {
|
||||
final status = packet.isOk ? 'OK' : packet.isError ? 'ERR' : 'NOT_FOUND';
|
||||
logger.i('<= [$tag] seq=${packet.seq} $status\n payload: ${packet.payload}');
|
||||
logger.i(
|
||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}',
|
||||
);
|
||||
|
||||
final completer = _pendingRequests.remove(packet.seq);
|
||||
_requestTimestamps.remove(packet.seq);
|
||||
@@ -67,12 +68,20 @@ class PacketDispatcher {
|
||||
}
|
||||
|
||||
if (packet.isError) {
|
||||
completer.completeError(packet);
|
||||
final message = messageFromErrorPayload(packet.payload);
|
||||
if (packet.payload is Map &&
|
||||
packet.payload['message'] == 'FAIL_LOGIN_TOKEN') {
|
||||
completer.completeError(SessionExpiredException(message));
|
||||
} else {
|
||||
completer.completeError(PacketError(message));
|
||||
}
|
||||
} else {
|
||||
completer.complete(packet);
|
||||
}
|
||||
} else if (packet.isPush) {
|
||||
logger.i('<= push [$tag] ${packet.payload}');
|
||||
logger.i(
|
||||
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}',
|
||||
);
|
||||
_pushHandlers[packet.opcode]?.call(packet);
|
||||
_pushController.add(packet);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../config/proxy_config.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси.
|
||||
/// Возвращает [RawSocket], который никогда не слушался —
|
||||
/// его можно передать в [RawSecureSocket.secure].
|
||||
class ProxyConnector {
|
||||
final ProxySettings settings;
|
||||
|
||||
ProxyConnector(this.settings);
|
||||
|
||||
Future<RawSocket> connect(String targetHost, int targetPort) async {
|
||||
switch (settings.type) {
|
||||
case ProxyType.socks5:
|
||||
return _connectSocks5(targetHost, targetPort);
|
||||
case ProxyType.httpConnect:
|
||||
return _connectHttpConnect(targetHost, targetPort);
|
||||
case ProxyType.none:
|
||||
return RawSocket.connect(targetHost, targetPort);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
|
||||
|
||||
Future<RawSocket> _connectSocks5(String targetHost, int targetPort) async {
|
||||
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
||||
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
|
||||
|
||||
final io = _RawSocketIO(proxySocket);
|
||||
try {
|
||||
// 1. Greeting
|
||||
final useAuth = settings.hasCredentials;
|
||||
if (useAuth) {
|
||||
await io.write([0x05, 0x02, 0x00, 0x02]);
|
||||
} else {
|
||||
await io.write([0x05, 0x01, 0x00]);
|
||||
}
|
||||
|
||||
var response = await io.readExact(2);
|
||||
if (response[0] != 0x05) {
|
||||
throw SocketException(
|
||||
'SOCKS5: неверная версия протокола: ${response[0]}',
|
||||
);
|
||||
}
|
||||
|
||||
final method = response[1];
|
||||
if (method == 0xFF) {
|
||||
throw SocketException(
|
||||
'SOCKS5: сервер отклонил все методы аутентификации',
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Аутентификация (RFC 1929)
|
||||
if (method == 0x02) {
|
||||
if (!useAuth) {
|
||||
throw SocketException('SOCKS5: прокси требует аутентификацию');
|
||||
}
|
||||
final usernameBytes = utf8.encode(settings.username!);
|
||||
final passwordBytes = utf8.encode(settings.password!);
|
||||
final authPacket = BytesBuilder()
|
||||
..addByte(0x01)
|
||||
..addByte(usernameBytes.length)
|
||||
..add(usernameBytes)
|
||||
..addByte(passwordBytes.length)
|
||||
..add(passwordBytes);
|
||||
await io.write(authPacket.toBytes());
|
||||
|
||||
final authResponse = await io.readExact(2);
|
||||
if (authResponse[1] != 0x00) {
|
||||
throw SocketException('SOCKS5: аутентификация не пройдена');
|
||||
}
|
||||
logger.i('SOCKS5: аутентификация пройдена');
|
||||
}
|
||||
|
||||
// 3. Connect request
|
||||
final hostBytes = utf8.encode(targetHost);
|
||||
final connectPacket = BytesBuilder()
|
||||
..addByte(0x05) // VER
|
||||
..addByte(0x01) // CMD: CONNECT
|
||||
..addByte(0x00) // RSV
|
||||
..addByte(0x03) // ATYP: domain
|
||||
..addByte(hostBytes.length)
|
||||
..add(hostBytes)
|
||||
..addByte((targetPort >> 8) & 0xFF)
|
||||
..addByte(targetPort & 0xFF);
|
||||
await io.write(connectPacket.toBytes());
|
||||
|
||||
// 4. Reply
|
||||
final reply = await io.readExact(4);
|
||||
if (reply[0] != 0x05) {
|
||||
throw SocketException('SOCKS5: неверная версия в ответе');
|
||||
}
|
||||
if (reply[1] != 0x00) {
|
||||
throw SocketException(
|
||||
'SOCKS5: ошибка подключения, код: ${reply[1]}',
|
||||
);
|
||||
}
|
||||
|
||||
// Пропускаем bind address
|
||||
switch (reply[3]) {
|
||||
case 0x01:
|
||||
await io.readExact(4 + 2);
|
||||
break;
|
||||
case 0x03:
|
||||
final lenBuf = await io.readExact(1);
|
||||
await io.readExact(lenBuf[0] + 2);
|
||||
break;
|
||||
case 0x04:
|
||||
await io.readExact(16 + 2);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен');
|
||||
|
||||
// Создаём локальную пару и проксируем данные
|
||||
return _bridgeToFreshSocket(proxySocket, io);
|
||||
} catch (e) {
|
||||
io.dispose();
|
||||
proxySocket.close();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP CONNECT ────────────────────────────────────────────────────────
|
||||
|
||||
Future<RawSocket> _connectHttpConnect(
|
||||
String targetHost,
|
||||
int targetPort,
|
||||
) async {
|
||||
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
||||
logger.i(
|
||||
'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}',
|
||||
);
|
||||
|
||||
final io = _RawSocketIO(proxySocket);
|
||||
try {
|
||||
final request = StringBuffer()
|
||||
..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n')
|
||||
..write('Host: $targetHost:$targetPort\r\n');
|
||||
|
||||
if (settings.hasCredentials) {
|
||||
final credentials = base64Encode(
|
||||
utf8.encode('${settings.username}:${settings.password}'),
|
||||
);
|
||||
request.write('Proxy-Authorization: Basic $credentials\r\n');
|
||||
}
|
||||
request.write('\r\n');
|
||||
|
||||
await io.write(utf8.encode(request.toString()));
|
||||
|
||||
// Читаем HTTP-ответ до \r\n\r\n
|
||||
final headerBytes = <int>[];
|
||||
while (true) {
|
||||
final byte = await io.readExact(1);
|
||||
headerBytes.add(byte[0]);
|
||||
if (headerBytes.length >= 4 &&
|
||||
headerBytes[headerBytes.length - 4] == 0x0D &&
|
||||
headerBytes[headerBytes.length - 3] == 0x0A &&
|
||||
headerBytes[headerBytes.length - 2] == 0x0D &&
|
||||
headerBytes[headerBytes.length - 1] == 0x0A) {
|
||||
break;
|
||||
}
|
||||
if (headerBytes.length > 8192) {
|
||||
throw SocketException(
|
||||
'HTTP CONNECT: заголовок ответа слишком большой',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final responseStr = utf8.decode(headerBytes, allowMalformed: true);
|
||||
final statusLine = responseStr.split('\r\n').first;
|
||||
final parts = statusLine.split(' ');
|
||||
final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0;
|
||||
if (statusCode != 200) {
|
||||
throw SocketException(
|
||||
'HTTP CONNECT: прокси вернул статус $statusCode',
|
||||
);
|
||||
}
|
||||
|
||||
logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен');
|
||||
return _bridgeToFreshSocket(proxySocket, io);
|
||||
} catch (e) {
|
||||
io.dispose();
|
||||
proxySocket.close();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Мост: создаём свежий сокет и проксируем через loopback ─────────────
|
||||
|
||||
/// После handshake proxy-сокет уже прослушан (single-subscription).
|
||||
/// Создаём пару локальных сокетов через loopback и проксируем данные
|
||||
/// между прокси-сокетом и одним концом. Второй конец возвращаем —
|
||||
/// он «свежий» и его можно передать в [RawSecureSocket.secure].
|
||||
Future<RawSocket> _bridgeToFreshSocket(
|
||||
RawSocket proxySocket,
|
||||
_RawSocketIO io,
|
||||
) async {
|
||||
final server = await RawServerSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
final clientSide = await RawSocket.connect(
|
||||
InternetAddress.loopbackIPv4,
|
||||
server.port,
|
||||
);
|
||||
final serverSide = await server.first;
|
||||
await server.close();
|
||||
|
||||
// proxy → local (через уже имеющуюся подписку _RawSocketIO)
|
||||
io.onData = (data) {
|
||||
serverSide.write(data);
|
||||
};
|
||||
io.onClosed = () {
|
||||
serverSide.shutdown(SocketDirection.send);
|
||||
};
|
||||
|
||||
// local → proxy
|
||||
serverSide.listen((event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final data = serverSide.read();
|
||||
if (data != null) proxySocket.write(data);
|
||||
} else if (event == RawSocketEvent.readClosed ||
|
||||
event == RawSocketEvent.closed) {
|
||||
proxySocket.shutdown(SocketDirection.send);
|
||||
}
|
||||
});
|
||||
|
||||
// Сливаем данные, буферизованные во время handshake
|
||||
io.flushBuffered();
|
||||
|
||||
logger.i('Прокси-мост через loopback создан');
|
||||
return clientSide;
|
||||
}
|
||||
}
|
||||
|
||||
/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения.
|
||||
///
|
||||
/// После handshake переключается в режим моста:
|
||||
/// данные из proxy-сокета пересылаются через [onData] в loopback-пару.
|
||||
class _RawSocketIO {
|
||||
final RawSocket _socket;
|
||||
late final StreamSubscription<RawSocketEvent> _sub;
|
||||
|
||||
final _readBuffer = <int>[];
|
||||
Completer<void>? _readWaiter;
|
||||
Completer<void>? _writeWaiter;
|
||||
bool _closed = false;
|
||||
Object? _error;
|
||||
|
||||
/// Коллбэк для данных в режиме моста.
|
||||
void Function(Uint8List data)? onData;
|
||||
|
||||
/// Коллбэк закрытия в режиме моста.
|
||||
void Function()? onClosed;
|
||||
|
||||
_RawSocketIO(this._socket) {
|
||||
_sub = _socket.listen(
|
||||
_onEvent,
|
||||
onError: (Object err) {
|
||||
_error = err;
|
||||
_closed = true;
|
||||
_readWaiter?.completeError(err);
|
||||
_readWaiter = null;
|
||||
_writeWaiter?.completeError(err);
|
||||
_writeWaiter = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onEvent(RawSocketEvent event) {
|
||||
switch (event) {
|
||||
case RawSocketEvent.read:
|
||||
final data = _socket.read();
|
||||
if (data != null) {
|
||||
if (onData != null) {
|
||||
// Режим моста — пересылаем напрямую
|
||||
onData!(data);
|
||||
} else {
|
||||
// Режим handshake — буферизуем
|
||||
_readBuffer.addAll(data);
|
||||
_readWaiter?.complete();
|
||||
_readWaiter = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case RawSocketEvent.write:
|
||||
_writeWaiter?.complete();
|
||||
_writeWaiter = null;
|
||||
break;
|
||||
case RawSocketEvent.readClosed:
|
||||
case RawSocketEvent.closed:
|
||||
_closed = true;
|
||||
onClosed?.call();
|
||||
_readWaiter?.completeError(
|
||||
SocketException('Прокси закрыл соединение'),
|
||||
);
|
||||
_readWaiter = null;
|
||||
_writeWaiter?.completeError(
|
||||
SocketException('Прокси закрыл соединение'),
|
||||
);
|
||||
_writeWaiter = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Читает ровно [count] байт.
|
||||
Future<Uint8List> readExact(int count) async {
|
||||
while (_readBuffer.length < count) {
|
||||
if (_error != null) throw _error!;
|
||||
if (_closed) {
|
||||
throw SocketException(
|
||||
'Соединение закрыто '
|
||||
'(ожидали $count байт, получили ${_readBuffer.length})',
|
||||
);
|
||||
}
|
||||
_readWaiter = Completer<void>();
|
||||
await _readWaiter!.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () =>
|
||||
throw SocketException('Тайм-аут при чтении от прокси'),
|
||||
);
|
||||
}
|
||||
final result = Uint8List.fromList(_readBuffer.sublist(0, count));
|
||||
_readBuffer.removeRange(0, count);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Записывает все байты.
|
||||
Future<void> write(List<int> data) async {
|
||||
var offset = 0;
|
||||
while (offset < data.length) {
|
||||
if (_error != null) throw _error!;
|
||||
if (_closed) throw SocketException('Соединение закрыто при записи');
|
||||
final written = _socket.write(data, offset);
|
||||
if (written > 0) {
|
||||
offset += written;
|
||||
} else {
|
||||
_writeWaiter = Completer<void>();
|
||||
await _writeWaiter!.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () =>
|
||||
throw SocketException('Тайм-аут при записи в прокси'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пересылает данные, оставшиеся в буфере после handshake, в мост.
|
||||
void flushBuffered() {
|
||||
if (_readBuffer.isNotEmpty && onData != null) {
|
||||
onData!(Uint8List.fromList(_readBuffer));
|
||||
_readBuffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,22 @@ class PacketReceiver {
|
||||
|
||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||
|
||||
/// Добавляет байты в буфер, возвращает все собранные пакеты.
|
||||
/// Добавляет байты в буфер, возвращает поток собранных пакетов.
|
||||
/// Неполные данные остаются в буфере до следующего вызова.
|
||||
List<Packet> feed(Uint8List data) {
|
||||
Stream<Packet> feed(Uint8List data) async* {
|
||||
final newBuffer = Uint8List(_buffer.length + data.length);
|
||||
newBuffer.setAll(0, _buffer);
|
||||
newBuffer.setAll(_buffer.length, data);
|
||||
_buffer = newBuffer;
|
||||
|
||||
if (_buffer.length > _maxBufferSize) {
|
||||
logger.e('PacketReceiver: переполнение буфера (${_buffer.length} B), сброс');
|
||||
logger.e(
|
||||
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
||||
);
|
||||
reset();
|
||||
return [];
|
||||
return;
|
||||
}
|
||||
|
||||
final packets = <Packet>[];
|
||||
|
||||
while (_buffer.length >= headerSize) {
|
||||
final bd = ByteData.view(
|
||||
_buffer.buffer,
|
||||
@@ -42,13 +42,11 @@ class PacketReceiver {
|
||||
_buffer = _buffer.sublist(totalLength);
|
||||
|
||||
try {
|
||||
packets.add(unpackPacket(packetBytes));
|
||||
yield await unpackPacket(packetBytes);
|
||||
} catch (e) {
|
||||
logger.e('PacketReceiver: ошибка распаковки: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return packets;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import '../protocol/packet.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'connection.dart';
|
||||
|
||||
/// Упаковывает и отправляет пакеты, ведёт счётчик seq.
|
||||
class PacketSender {
|
||||
int _seq = 0;
|
||||
|
||||
@@ -14,12 +12,13 @@ class PacketSender {
|
||||
return _seq;
|
||||
}
|
||||
|
||||
/// Отправляет пакет, возвращает присвоенный seq.
|
||||
int send(Connection connection, int opcode, Map<dynamic, dynamic> payload) {
|
||||
final seq = _nextSeq();
|
||||
final data = packPacket(opcode, payload, seq: seq);
|
||||
connection.write(data);
|
||||
logger.i('=> [${Opcode.name(opcode)}] seq=$seq\n payload: $payload');
|
||||
logger.i(
|
||||
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: $payload}',
|
||||
);
|
||||
return seq;
|
||||
}
|
||||
}
|
||||
|
||||
+138
-9
@@ -1,13 +1,142 @@
|
||||
import 'package:logger/logger.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
Level _minimumLogLevel() {
|
||||
const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: '');
|
||||
switch (raw.toLowerCase()) {
|
||||
case 'trace':
|
||||
return Level.trace;
|
||||
case 'debug':
|
||||
return Level.debug;
|
||||
case 'info':
|
||||
return Level.info;
|
||||
case 'warning':
|
||||
case 'warn':
|
||||
return Level.warning;
|
||||
case 'error':
|
||||
return Level.error;
|
||||
case 'fatal':
|
||||
return Level.fatal;
|
||||
case 'off':
|
||||
return Level.off;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (kReleaseMode) {
|
||||
return Level.info;
|
||||
}
|
||||
return Level.trace;
|
||||
}
|
||||
|
||||
LogFilter _logFilter() {
|
||||
if (kReleaseMode) {
|
||||
return ProductionFilter();
|
||||
}
|
||||
return DevelopmentFilter();
|
||||
}
|
||||
|
||||
final logger = Logger(
|
||||
level: kReleaseMode ? Level.info : Level.all,
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
errorMethodCount: 5,
|
||||
lineLength: 80,
|
||||
colors: true,
|
||||
printEmojis: true,
|
||||
),
|
||||
filter: _logFilter(),
|
||||
level: _minimumLogLevel(),
|
||||
printer: KometLogPrinter(),
|
||||
);
|
||||
|
||||
int _importanceSortKey(Level level) {
|
||||
final v = level.value;
|
||||
if (v >= 5999) {
|
||||
return 0;
|
||||
}
|
||||
if (v >= 5000) {
|
||||
return 1;
|
||||
}
|
||||
if (v >= 4000) {
|
||||
return 2;
|
||||
}
|
||||
if (v >= 3000) {
|
||||
return 3;
|
||||
}
|
||||
if (v >= 2000) {
|
||||
return 4;
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
String _levelLetter(Level level) {
|
||||
final v = level.value;
|
||||
if (v >= 5999) {
|
||||
return 'F';
|
||||
}
|
||||
if (v >= 5000) {
|
||||
return 'E';
|
||||
}
|
||||
if (v >= 4000) {
|
||||
return 'W';
|
||||
}
|
||||
if (v >= 3000) {
|
||||
return 'I';
|
||||
}
|
||||
if (v >= 2000) {
|
||||
return 'D';
|
||||
}
|
||||
return 'T';
|
||||
}
|
||||
|
||||
AnsiColor _levelColor(Level level) {
|
||||
final v = level.value;
|
||||
if (v >= 5999) {
|
||||
return const AnsiColor.fg(199);
|
||||
}
|
||||
if (v >= 5000) {
|
||||
return const AnsiColor.fg(196);
|
||||
}
|
||||
if (v >= 4000) {
|
||||
return const AnsiColor.fg(208);
|
||||
}
|
||||
if (v >= 3000) {
|
||||
return const AnsiColor.fg(12);
|
||||
}
|
||||
if (v >= 2000) {
|
||||
return const AnsiColor.none();
|
||||
}
|
||||
return AnsiColor.fg(AnsiColor.grey(0.5));
|
||||
}
|
||||
|
||||
class KometLogPrinter extends LogPrinter {
|
||||
KometLogPrinter({this.colors = true});
|
||||
|
||||
final bool colors;
|
||||
|
||||
@override
|
||||
List<String> log(LogEvent event) {
|
||||
final lines = <String>[];
|
||||
final t = event.time;
|
||||
final timeStr =
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}';
|
||||
final ord = _importanceSortKey(event.level).toString().padLeft(2, '0');
|
||||
final letter = _levelLetter(event.level);
|
||||
final label = colors ? _levelColor(event.level)(letter) : letter;
|
||||
final msg = _stringifyMessage(event.message);
|
||||
lines.add('$ord|$timeStr $label $msg');
|
||||
if (event.error != null) {
|
||||
lines.add('${' ' * 3}| ${event.error}');
|
||||
}
|
||||
if (event.stackTrace != null && event.level.value >= 5000) {
|
||||
final st = event.stackTrace.toString().split('\n');
|
||||
const limit = 12;
|
||||
for (var i = 0; i < st.length && i < limit; i++) {
|
||||
lines.add('${' ' * 3}| ${st[i]}');
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
String _stringifyMessage(dynamic message) {
|
||||
final finalMessage = message is Function ? message() : message;
|
||||
if (finalMessage is Map || finalMessage is Iterable) {
|
||||
return const JsonEncoder.withIndent(null).convert(finalMessage);
|
||||
}
|
||||
return finalMessage.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class FpsOverlayLayer extends StatefulWidget {
|
||||
const FpsOverlayLayer({super.key});
|
||||
|
||||
@override
|
||||
State<FpsOverlayLayer> createState() => _FpsOverlayLayerState();
|
||||
}
|
||||
|
||||
class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
|
||||
static const int _maxSamples = 90;
|
||||
static const int _minUiRefreshMs = 160;
|
||||
static const double _initialWidthGuess = 96;
|
||||
static const double _initialHeightGuess = 36;
|
||||
|
||||
final List<int> _frameMicros = <int>[];
|
||||
final GlobalKey _badgeKey = GlobalKey();
|
||||
double _fps = 0;
|
||||
DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
double? _left;
|
||||
double? _top;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addTimingsCallback(_onTimings);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeTimingsCallback(_onTimings);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_left != null && _top != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(_clampPositionToScreen);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureInitialPosition() {
|
||||
if (_left != null) return;
|
||||
final mq = MediaQuery.of(context);
|
||||
final w = mq.size.width;
|
||||
_left = w - _initialWidthGuess - 8;
|
||||
_top = mq.padding.top + 8;
|
||||
}
|
||||
|
||||
void _clampPositionToScreen() {
|
||||
if (_left == null || _top == null) return;
|
||||
final mq = MediaQuery.of(context);
|
||||
final screen = mq.size;
|
||||
final topMin = mq.padding.top;
|
||||
final bottomMax = screen.height - mq.padding.bottom;
|
||||
|
||||
final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final bw = box?.hasSize == true
|
||||
? box!.size.width
|
||||
: _initialWidthGuess;
|
||||
final bh = box?.hasSize == true
|
||||
? box!.size.height
|
||||
: _initialHeightGuess;
|
||||
|
||||
_left = _left!.clamp(0.0, math.max(0.0, screen.width - bw));
|
||||
_top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh));
|
||||
}
|
||||
|
||||
void _onTimings(List<FrameTiming> timings) {
|
||||
for (final t in timings) {
|
||||
final us = t.totalSpan.inMicroseconds;
|
||||
if (us <= 0) continue;
|
||||
_frameMicros.add(us);
|
||||
while (_frameMicros.length > _maxSamples) {
|
||||
_frameMicros.removeAt(0);
|
||||
}
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) {
|
||||
return;
|
||||
}
|
||||
_lastUiUpdate = now;
|
||||
if (!mounted || _frameMicros.isEmpty) return;
|
||||
final sum = _frameMicros.fold<int>(0, (a, b) => a + b);
|
||||
final avg = sum / _frameMicros.length;
|
||||
final fps = avg > 0 ? (1000000.0 / avg).clamp(0.0, 999.0) : 0.0;
|
||||
setState(() => _fps = fps);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_ensureInitialPosition();
|
||||
_clampPositionToScreen();
|
||||
|
||||
return Positioned(
|
||||
left: _left,
|
||||
top: _top,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.move,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
_left = _left! + details.delta.dx;
|
||||
_top = _top! + details.delta.dy;
|
||||
_clampPositionToScreen();
|
||||
});
|
||||
},
|
||||
child: Material(
|
||||
key: _badgeKey,
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC000000),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${_fps.round()} FPS',
|
||||
style: TextStyle(
|
||||
color: _fps >= 55
|
||||
? const Color(0xFFB8F5C6)
|
||||
: _fps >= 30
|
||||
? const Color(0xFFFFE082)
|
||||
: const Color(0xFFFFAB91),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../chats/chat_list_screen.dart';
|
||||
import 'password_2fa_screen.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class CodeConfirmationScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
final String token;
|
||||
|
||||
const CodeConfirmationScreen({
|
||||
super.key,
|
||||
super.key,
|
||||
required this.phoneNumber,
|
||||
required this.token,
|
||||
});
|
||||
@@ -20,11 +22,17 @@ class CodeConfirmationScreen extends StatefulWidget {
|
||||
State<CodeConfirmationScreen> createState() => _CodeConfirmationScreenState();
|
||||
}
|
||||
|
||||
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _codeController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
int _timerSeconds = 30;
|
||||
Timer? _timer;
|
||||
Timer? _errorTimer;
|
||||
|
||||
String? _errorMessage;
|
||||
late AnimationController _shakeController;
|
||||
late Animation<double> _shakeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,11 +41,26 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
|
||||
_shakeController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
_shakeAnimation = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: 8.0, end: -8.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: 8.0, end: -4.0), weight: 2),
|
||||
TweenSequenceItem(tween: Tween(begin: -4.0, end: 0.0), weight: 1),
|
||||
]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_errorTimer?.cancel();
|
||||
_shakeController.dispose();
|
||||
_codeController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
@@ -57,11 +80,18 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
_errorTimer?.cancel();
|
||||
_shakeController.forward(from: 0);
|
||||
setState(() => _errorMessage = message);
|
||||
_errorTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) setState(() => _errorMessage = null);
|
||||
});
|
||||
}
|
||||
|
||||
void _resendCode() {
|
||||
if (_timerSeconds == 0) {
|
||||
_startTimer();
|
||||
// TODO: вызвать accountModule.resendCode
|
||||
print('Resending code to ${widget.phoneNumber}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,29 +108,27 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
|
||||
if (result.requiresPassword) {
|
||||
final trackId = result.challengeTrackId;
|
||||
|
||||
|
||||
if (trackId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: отсутствуют данные для 2FA')),
|
||||
showCustomNotification(
|
||||
context,
|
||||
AppLocalizations.of(context)!.codeError2faMissing,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Password2FAScreen(
|
||||
trackId: trackId,
|
||||
hint: result.challengeHint,
|
||||
),
|
||||
builder: (context) =>
|
||||
Password2FAScreen(trackId: trackId, hint: result.challengeHint),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если 2FA не требуется, делаем login
|
||||
final loginResult = await accountModule.login();
|
||||
|
||||
await accountModule.login();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pushAndRemoveUntil(
|
||||
@@ -110,19 +138,16 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: $e')),
|
||||
);
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToChats() {
|
||||
_verifyCode();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final hasError = _errorMessage != null;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
@@ -150,7 +175,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Мы отправили SMS с кодом подтверждения на ваш номер телефона.',
|
||||
l10n.codeConfirmationSmsSent,
|
||||
style: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 15,
|
||||
@@ -159,92 +184,156 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Stack(
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0,
|
||||
child: SizedBox(
|
||||
height: 0,
|
||||
width: 0,
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
if (value.length == 6) {
|
||||
_navigateToChats();
|
||||
}
|
||||
},
|
||||
AnimatedBuilder(
|
||||
animation: _shakeAnimation,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(_shakeAnimation.value, 0),
|
||||
child: child,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0,
|
||||
child: SizedBox(
|
||||
height: 0,
|
||||
width: 0,
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (hasError) setState(() => _errorMessage = null);
|
||||
setState(() {});
|
||||
if (value.length == 6) _verifyCode();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _focusNode.requestFocus(),
|
||||
child: FittedBox(
|
||||
child: Row(
|
||||
children: List.generate(6, (index) {
|
||||
bool isFocused = _codeController.text.length == index && _focusNode.hasFocus;
|
||||
bool hasValue = _codeController.text.length > index;
|
||||
String char = hasValue ? _codeController.text[index] : '';
|
||||
GestureDetector(
|
||||
onTap: () => _focusNode.requestFocus(),
|
||||
child: FittedBox(
|
||||
child: Row(
|
||||
children: List.generate(6, (index) {
|
||||
final isFocused =
|
||||
_codeController.text.length == index &&
|
||||
_focusNode.hasFocus;
|
||||
final hasValue =
|
||||
_codeController.text.length > index;
|
||||
final char = hasValue
|
||||
? _codeController.text[index]
|
||||
: '';
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 54,
|
||||
margin: EdgeInsets.only(right: index == 5 ? 0 : 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isFocused
|
||||
? cs.primary
|
||||
: (hasValue ? cs.outlineVariant : Colors.transparent),
|
||||
width: 1.5,
|
||||
Color borderColor;
|
||||
if (hasError && hasValue) {
|
||||
borderColor = cs.error;
|
||||
} else if (isFocused) {
|
||||
borderColor = cs.primary;
|
||||
} else if (hasValue) {
|
||||
borderColor = cs.outlineVariant;
|
||||
} else {
|
||||
borderColor = Colors.transparent;
|
||||
}
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 44,
|
||||
height: 54,
|
||||
margin: EdgeInsets.only(
|
||||
right: index == 5 ? 0 : 10,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
char,
|
||||
key: ValueKey<String>(char + index.toString()),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: BoxDecoration(
|
||||
color: hasError && hasValue
|
||||
? cs.error.withValues(alpha: 0.1)
|
||||
: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
transitionBuilder:
|
||||
(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
char,
|
||||
key: ValueKey<String>(
|
||||
char +
|
||||
index.toString() +
|
||||
(hasError ? 'e' : ''),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: hasError && hasValue
|
||||
? cs.error
|
||||
: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topLeft,
|
||||
child: hasError
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: AnimatedOpacity(
|
||||
opacity: hasError ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: cs.error,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GestureDetector(
|
||||
onTap: _resendCode,
|
||||
child: Text(
|
||||
_timerSeconds > 0
|
||||
? 'Отправить повторно через $_timerSeconds сек.'
|
||||
: 'Отправить код по SMS',
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
style: TextStyle(
|
||||
color: cs.tertiary,
|
||||
color: _timerSeconds > 0 ? cs.outline : cs.tertiary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
child: Text(
|
||||
_timerSeconds > 0
|
||||
? l10n.codeResendInSeconds(_timerSeconds)
|
||||
: l10n.codeResendSms,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -253,9 +342,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
onPressed: () {
|
||||
if (_codeController.text.length == 6) {
|
||||
_navigateToChats();
|
||||
}
|
||||
if (_codeController.text.length == 6) _verifyCode();
|
||||
},
|
||||
backgroundColor: _codeController.text.length == 6
|
||||
? cs.primaryContainer
|
||||
@@ -266,7 +353,9 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_forward,
|
||||
color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
color: _codeController.text.length == 6
|
||||
? cs.onPrimaryContainer
|
||||
: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:komet/core/config/countries.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:komet/l10n/terms_of_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'code_confirmation_screen.dart';
|
||||
import 'select_country_screen.dart';
|
||||
import 'spoff_redacted_screen.dart';
|
||||
import 'proxy_settings_sheet.dart';
|
||||
import 'server_settings_sheet.dart';
|
||||
import '../profile/spoof_screen.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../../main.dart';
|
||||
|
||||
@@ -23,14 +28,33 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
late CountryName _selectedCountry;
|
||||
bool _isPhoneValid = false;
|
||||
bool _isTOSRead = false;
|
||||
String? _phoneError;
|
||||
Timer? _phoneErrorTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedCountry = countriesByCode['RU'] ?? allCountries.first;
|
||||
_clampCountryToAllowed();
|
||||
_checkTOS();
|
||||
}
|
||||
|
||||
void _clampCountryToAllowed() {
|
||||
final allowed = api.registrationCountries;
|
||||
if (allowed.any((c) => c.code == _selectedCountry.code)) return;
|
||||
_selectedCountry = allowed.firstWhere(
|
||||
(c) => c.code == 'RU',
|
||||
orElse: () => allowed.first,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneErrorTimer?.cancel();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkTOS() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
@@ -54,8 +78,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final result = await Navigator.push<CountryName>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
SelectCountryScreen(selectedCountry: _selectedCountry),
|
||||
builder: (context) => SelectCountryScreen(
|
||||
selectedCountry: _selectedCountry,
|
||||
countries: api.registrationCountries,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
@@ -67,8 +93,86 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
String _countryDisplayName(CountryName country) {
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
return lang == 'ru' ? country.ru : country.en;
|
||||
}
|
||||
|
||||
String _phoneMaskHint(CountryName country) {
|
||||
return country.phoneMask.replaceAll('#', '0');
|
||||
}
|
||||
|
||||
void _showLanguagePicker() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final appContext = context;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24.0,
|
||||
horizontal: 16.0,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||
child: Text(
|
||||
l10n.loginLanguage,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
l10n.languageNameRu,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
KometApp.stateOf(appContext)?.applyLocale(const Locale('ru'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
l10n.languageNameEn,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
KometApp.stateOf(appContext)?.applyLocale(const Locale('en'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showTOS(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final termsLocale = Localizations.localeOf(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
@@ -97,7 +201,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Условия использования',
|
||||
AppLocalizations.of(context)!.loginTermsOfUse,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
@@ -137,99 +241,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
children: [
|
||||
//У мя вопрос а тут точно номера статей верные???
|
||||
Text(
|
||||
'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n'
|
||||
'1. Статус и отношения\n'
|
||||
'1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n'
|
||||
'1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n'
|
||||
'1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n'
|
||||
'2. Условия использования\n'
|
||||
'2.1. Используя Приложение «Komet», вы:\n'
|
||||
'• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n'
|
||||
'• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n'
|
||||
'• Принимаете на себя все риски, связанные с использованием Приложения.\n'
|
||||
'2.2. Строго запрещено:\n'
|
||||
'• Использовать Приложение «Komet» для распространения запрещённого контента;\n'
|
||||
'• Осуществлять массовые рассылки (спам);\n'
|
||||
'• Нарушать законодательство РФ и международное право;\n'
|
||||
'• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n'
|
||||
'2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n'
|
||||
'2.4. Особенности технического взаимодействия:\n'
|
||||
'• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n'
|
||||
'• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n'
|
||||
'• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n'
|
||||
'3. Технические аспекты\n'
|
||||
'3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n'
|
||||
'3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n'
|
||||
'3.3. Разработчики не несут ответственности за:\n'
|
||||
'• Изменения в API оригинального сервиса;\n'
|
||||
'• Блокировку аккаунтов пользователей;\n'
|
||||
'• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n'
|
||||
'4. Конфиденциальность\n'
|
||||
'4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n'
|
||||
'4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n'
|
||||
'4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n'
|
||||
'5. Ответственность и ограничения\n'
|
||||
'5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n'
|
||||
'5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n'
|
||||
'6. Правовые основания\n'
|
||||
'6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n'
|
||||
'• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n'
|
||||
'• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n'
|
||||
'• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n'
|
||||
'• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n'
|
||||
'6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n'
|
||||
'• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n'
|
||||
'• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n'
|
||||
'• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n'
|
||||
'6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n'
|
||||
'7. Заключительные положения\n'
|
||||
'7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n'
|
||||
'• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n'
|
||||
'• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n'
|
||||
'• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n'
|
||||
'7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n'
|
||||
'8. Функции безопасности и конфиденциальности\n'
|
||||
'8.1. Приложение «Komet» включает инструменты защиты приватности:\n'
|
||||
'• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n'
|
||||
'• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n'
|
||||
'• Ограничение телеметрии — для минимизации передачи диагностических данных.\n'
|
||||
'8.2. Данные функции:\n'
|
||||
'• Направлены исключительно на защиту конфиденциальности пользователей;\n'
|
||||
'• Не используются для обхода систем безопасности оригинального сервиса;\n'
|
||||
'• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n'
|
||||
'8.3. Разработчики не несут ответственности за:\n'
|
||||
'• Блокировки, связанные с использованием инструментов конфиденциальности;\n'
|
||||
'• Изменения в работе сервиса при активации данных функций.\n'
|
||||
'8.4. Функции экспорта и импорта сессии\n'
|
||||
'8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n'
|
||||
'• Обеспечения переносимости данных между устройствами пользователя\n'
|
||||
'• Резервного копирования учетных данных\n'
|
||||
'• Восстановления доступа при утере устройства\n'
|
||||
'8.4.2. Особенности реализации:\n'
|
||||
'• Экспорт сессии осуществляется без привязки к номеру телефона\n'
|
||||
'• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n'
|
||||
'• Ключ шифрования известен только пользователю и не сохраняется в приложении\n'
|
||||
'8.4.3. Техническая реализация экспорта сессии:\n'
|
||||
'• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n'
|
||||
'• Используется подмена параметров сессии для сохранения контекста аутентификации\n'
|
||||
'• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n'
|
||||
'• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n'
|
||||
'• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n'
|
||||
'• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n'
|
||||
'8.4.4. Правовые основания:\n'
|
||||
'• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n'
|
||||
'• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n'
|
||||
'• Принцип минимизации данных — сбор только необходимой для работы информации\n'
|
||||
'• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n'
|
||||
'• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n'
|
||||
'• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n'
|
||||
'8.4.5. Ограничения ответственности:\n'
|
||||
'• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n'
|
||||
'• Разработчики не имеют доступа к зашифрованным данным сессии\n'
|
||||
'• Восстановление утерянных паролей невозможно в целях безопасности\n'
|
||||
'• Ключи шифрования не хранятся в приложении и известны только пользователю',
|
||||
termsOfServiceBody(termsLocale),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
@@ -308,9 +321,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showPhoneError(String message) {
|
||||
_phoneErrorTimer?.cancel();
|
||||
setState(() => _phoneError = message);
|
||||
_phoneErrorTimer = Timer(const Duration(seconds: 4), () {
|
||||
if (mounted) setState(() => _phoneError = null);
|
||||
});
|
||||
}
|
||||
|
||||
void _showPhoneConfirmationDialog(String formattedPhone) {
|
||||
final screenContext = context;
|
||||
final l10n = AppLocalizations.of(screenContext)!;
|
||||
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
context: screenContext,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: '',
|
||||
barrierColor: Colors.black54,
|
||||
@@ -338,7 +362,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Это правильный номер?',
|
||||
l10n.loginConfirmPhoneTitle,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
@@ -363,7 +387,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
'Изменить',
|
||||
l10n.loginEdit,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.primary,
|
||||
fontSize: 15,
|
||||
@@ -374,31 +398,33 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
|
||||
final fullPhone = '${_selectedCountry.phoneCode}${_phoneController.text}';
|
||||
|
||||
|
||||
final fullPhone =
|
||||
'${_selectedCountry.phoneCode}${_phoneController.text}';
|
||||
|
||||
try {
|
||||
final result = await accountModule.requestCode(fullPhone);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CodeConfirmationScreen(
|
||||
phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone',
|
||||
token: result.token,
|
||||
),
|
||||
final result = await accountModule.requestCode(
|
||||
fullPhone,
|
||||
);
|
||||
|
||||
if (!screenContext.mounted) return;
|
||||
Navigator.push(
|
||||
screenContext,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CodeConfirmationScreen(
|
||||
phoneNumber:
|
||||
'${_selectedCountry.phoneCode} $formattedPhone',
|
||||
token: result.token,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
}
|
||||
if (!screenContext.mounted) return;
|
||||
_showPhoneError(e.toString());
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
'Готово',
|
||||
l10n.loginDone,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.primary,
|
||||
fontSize: 15,
|
||||
@@ -419,21 +445,59 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
void _validateAndSubmit() {
|
||||
if (!_isTOSRead) {
|
||||
showCustomNotification(context, 'Соглащение прочитай щегол');
|
||||
showCustomNotification(
|
||||
context,
|
||||
AppLocalizations.of(context)!.loginReadTermsNotification,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_showPhoneConfirmationDialog(_phoneController.text);
|
||||
}
|
||||
|
||||
void _showServerSettingsSheet(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ServerSettingsSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showProxySettingsSheet(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ProxySettingsSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSecurityOptions(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (context) {
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -446,7 +510,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ListTile(
|
||||
leading: Icon(Symbols.security, color: cs.onSurface),
|
||||
title: Text(
|
||||
'Подделка спуфа',
|
||||
l10n.loginSpoofRedacted,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
@@ -454,11 +518,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SpoffRedactedScreen(),
|
||||
builder: (context) => const SpoofScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -466,7 +530,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ListTile(
|
||||
leading: Icon(Symbols.vpn_lock, color: cs.onSurface),
|
||||
title: Text(
|
||||
'Прокси',
|
||||
l10n.loginProxy,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
@@ -474,7 +538,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
_showProxySettingsSheet(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Symbols.dns, color: cs.onSurface),
|
||||
title: Text(
|
||||
l10n.loginChangeServer,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showServerSettingsSheet(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -487,6 +567,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
void _showOtherLoginMethods(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
@@ -506,7 +587,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ListTile(
|
||||
leading: Icon(Symbols.qr_code_2, color: cs.onSurface),
|
||||
title: Text(
|
||||
'По QR code',
|
||||
l10n.loginSignInWithQr,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
@@ -520,7 +601,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ListTile(
|
||||
leading: Icon(Symbols.key, color: cs.onSurface),
|
||||
title: Text(
|
||||
'По токену',
|
||||
l10n.loginSignInWithToken,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
@@ -534,7 +615,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ListTile(
|
||||
leading: Icon(Symbols.description, color: cs.onSurface),
|
||||
title: Text(
|
||||
'По файлу сессии',
|
||||
l10n.loginSignInWithSessionFile,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
@@ -556,6 +637,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: GestureDetector(
|
||||
@@ -593,7 +675,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
onPressed: _showLanguagePicker,
|
||||
icon: Icon(
|
||||
Symbols.language,
|
||||
color: cs.onSurfaceVariant,
|
||||
@@ -613,7 +695,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Войдите в Komet',
|
||||
l10n.loginTitle,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 32,
|
||||
@@ -622,7 +704,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Проверьте код страны и введите свой\nномер телефона.',
|
||||
l10n.loginSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
@@ -639,11 +721,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
onTap: _showCountryPicker,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: _buildInputField(
|
||||
label: 'Страна',
|
||||
label: l10n.loginCountry,
|
||||
content: Row(
|
||||
children: [
|
||||
Text(
|
||||
_selectedCountry.ru,
|
||||
_countryDisplayName(_selectedCountry),
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
@@ -661,7 +743,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
_buildInputField(
|
||||
label: 'Номер телефона',
|
||||
label: l10n.loginPhoneNumber,
|
||||
content: Row(
|
||||
children: [
|
||||
Text(
|
||||
@@ -681,6 +763,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: ValueKey(_selectedCountry.code),
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
@@ -693,7 +776,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '(000) 000-00-00',
|
||||
hintText: _phoneMaskHint(_selectedCountry),
|
||||
hintStyle: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 15,
|
||||
@@ -719,11 +802,32 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 8),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _phoneError != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_phoneError!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => _showOtherLoginMethods(context),
|
||||
child: Text(
|
||||
'Другие способы входа',
|
||||
l10n.loginOtherSignInMethods,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.primary,
|
||||
fontSize: 14,
|
||||
@@ -749,8 +853,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text:
|
||||
'Продолжая, вы соглашаетесь с \n',
|
||||
text: l10n.loginTermsIntro,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 14,
|
||||
@@ -759,8 +862,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text:
|
||||
'пользовательскими соглашениями',
|
||||
text: l10n.loginTermsLink,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.primary,
|
||||
fontSize: 14,
|
||||
|
||||
@@ -2,16 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../chats/chat_list_screen.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class Password2FAScreen extends StatefulWidget {
|
||||
final String trackId;
|
||||
final String? hint;
|
||||
|
||||
const Password2FAScreen({
|
||||
super.key,
|
||||
required this.trackId,
|
||||
this.hint,
|
||||
});
|
||||
const Password2FAScreen({super.key, required this.trackId, this.hint});
|
||||
|
||||
@override
|
||||
State<Password2FAScreen> createState() => _Password2FAScreenState();
|
||||
@@ -43,8 +40,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// После успешной 2FA делаем login
|
||||
final loginResult = await accountModule.login();
|
||||
await accountModule.login();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -60,9 +56,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Неверный пароль: $e')),
|
||||
);
|
||||
showCustomNotification(context, 'Неверный пароль: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +125,9 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordVisible ? Icons.visibility_off : Icons.visibility,
|
||||
_isPasswordVisible
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:komet/backend/api.dart';
|
||||
import 'package:komet/core/config/proxy_config.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class ProxySettingsSheet extends StatefulWidget {
|
||||
const ProxySettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
State<ProxySettingsSheet> createState() => _ProxySettingsSheetState();
|
||||
}
|
||||
|
||||
class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
final _hostController = TextEditingController();
|
||||
final _portController = TextEditingController(text: '1080');
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
ProxyType _selectedType = ProxyType.none;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final settings = await ProxyConfig.load();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedType = settings.type;
|
||||
_hostController.text = settings.host;
|
||||
_portController.text = '${settings.port}';
|
||||
_usernameController.text = settings.username ?? '';
|
||||
_passwordController.text = settings.password ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _apply(AppLocalizations l10n) async {
|
||||
if (_selectedType == ProxyType.none) {
|
||||
return _disable(l10n);
|
||||
}
|
||||
|
||||
final host = _hostController.text.trim();
|
||||
final port = int.tryParse(_portController.text.trim());
|
||||
if (host.isEmpty || port == null || port < 1 || port > 65535) {
|
||||
showCustomNotification(context, l10n.proxyInvalidHostOrPort);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
await ProxyConfig.save(ProxySettings(
|
||||
type: _selectedType,
|
||||
host: host,
|
||||
port: port,
|
||||
username: username.isNotEmpty ? username : null,
|
||||
password: password.isNotEmpty ? password : null,
|
||||
));
|
||||
await api.disconnect();
|
||||
await api.connect();
|
||||
if (!mounted) return;
|
||||
if (api.state == SessionState.online) {
|
||||
showCustomNotification(context, l10n.proxySettingsSaved);
|
||||
} else {
|
||||
showCustomNotification(context, l10n.serverReconnectFailed);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disable(AppLocalizations l10n) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ProxyConfig.clear();
|
||||
setState(() => _selectedType = ProxyType.none);
|
||||
await api.disconnect();
|
||||
await api.connect();
|
||||
if (!mounted) return;
|
||||
if (api.state == SessionState.online) {
|
||||
showCustomNotification(context, l10n.proxySettingsSaved);
|
||||
} else {
|
||||
showCustomNotification(context, l10n.serverReconnectFailed);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostController.dispose();
|
||||
_portController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final isActive = _selectedType != ProxyType.none;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.proxySettingsTitle,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Proxy type selector
|
||||
_buildTypeSelector(cs, l10n),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Fields shown only when proxy is enabled
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: isActive
|
||||
? Column(
|
||||
children: [
|
||||
_buildTextField(
|
||||
controller: _hostController,
|
||||
label: l10n.proxyHostLabel,
|
||||
hintText: '127.0.0.1',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _portController,
|
||||
label: l10n.proxyPortLabel,
|
||||
hintText: _selectedType == ProxyType.socks5
|
||||
? '1080'
|
||||
: '8080',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _usernameController,
|
||||
label: l10n.proxyUsernameLabel,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.text,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _passwordController,
|
||||
label: l10n.proxyPasswordLabel,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : () => _apply(l10n),
|
||||
child: Text(
|
||||
isActive ? l10n.proxyApply : l10n.proxyDisable,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) {
|
||||
final labels = {
|
||||
ProxyType.none: l10n.proxyTypeNone,
|
||||
ProxyType.socks5: l10n.proxyTypeSocks5,
|
||||
ProxyType.httpConnect: l10n.proxyTypeHttp,
|
||||
};
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(
|
||||
children: ProxyType.values.map((type) {
|
||||
final selected = _selectedType == type;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _busy ? null : () => setState(() => _selectedType = type),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? cs.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labels[type]!,
|
||||
style: GoogleFonts.inter(
|
||||
color: selected ? cs.onPrimary : cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required ColorScheme cs,
|
||||
String? hintText,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
bool obscureText = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: !_busy,
|
||||
obscureText: obscureText,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 15,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:komet/core/config/countries.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
class SelectCountryScreen extends StatefulWidget {
|
||||
final CountryName selectedCountry;
|
||||
final List<CountryName> countries;
|
||||
|
||||
const SelectCountryScreen({super.key, required this.selectedCountry});
|
||||
SelectCountryScreen({
|
||||
super.key,
|
||||
required this.selectedCountry,
|
||||
List<CountryName>? countries,
|
||||
}) : countries = countries ?? allCountries;
|
||||
|
||||
@override
|
||||
State<SelectCountryScreen> createState() => _SelectCountryScreenState();
|
||||
@@ -15,7 +21,13 @@ class SelectCountryScreen extends StatefulWidget {
|
||||
class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
bool _isSearching = false;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<CountryName> _filteredCountries = allCountries;
|
||||
late List<CountryName> _filteredCountries;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filteredCountries = widget.countries;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -26,10 +38,10 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
void _filterCountries(String query) {
|
||||
setState(() {
|
||||
if (query.isEmpty) {
|
||||
_filteredCountries = allCountries;
|
||||
_filteredCountries = widget.countries;
|
||||
} else {
|
||||
final q = query.toLowerCase();
|
||||
_filteredCountries = allCountries.where((c) {
|
||||
_filteredCountries = widget.countries.where((c) {
|
||||
return c.ru.toLowerCase().contains(q) ||
|
||||
c.en.toLowerCase().contains(q) ||
|
||||
c.phoneCode.contains(q);
|
||||
@@ -41,6 +53,8 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
@@ -61,7 +75,7 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Поиск страны...',
|
||||
hintText: l10n.selectCountrySearchHint,
|
||||
hintStyle: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 18,
|
||||
@@ -72,7 +86,7 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
onChanged: _filterCountries,
|
||||
)
|
||||
: Text(
|
||||
'Выберите страну',
|
||||
l10n.selectCountryTitle,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
@@ -87,7 +101,7 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
_isSearching = !_isSearching;
|
||||
if (!_isSearching) {
|
||||
_searchController.clear();
|
||||
_filteredCountries = allCountries;
|
||||
_filteredCountries = widget.countries;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -114,7 +128,7 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
country.ru,
|
||||
lang == 'ru' ? country.ru : country.en,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:komet/backend/api.dart';
|
||||
import 'package:komet/core/config/config.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class ServerSettingsSheet extends StatefulWidget {
|
||||
const ServerSettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
State<ServerSettingsSheet> createState() => _ServerSettingsSheetState();
|
||||
}
|
||||
|
||||
class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
final TextEditingController _hostController = TextEditingController(
|
||||
text: ServerConfig.defaultHost,
|
||||
);
|
||||
final TextEditingController _portController = TextEditingController(
|
||||
text: '${ServerConfig.defaultPort}',
|
||||
);
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final endpoint = await ServerConfig.loadEndpoint();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hostController.text = endpoint.host;
|
||||
_portController.text = '${endpoint.port}';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _apply(AppLocalizations l10n) async {
|
||||
final host = _hostController.text.trim();
|
||||
final port = int.tryParse(_portController.text.trim());
|
||||
if (host.isEmpty || port == null || port < 1 || port > 65535) {
|
||||
showCustomNotification(context, l10n.serverInvalidHostOrPort);
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(ServerConfig.prefHostKey, host);
|
||||
await prefs.setInt(ServerConfig.prefPortKey, port);
|
||||
await api.disconnect();
|
||||
api.connect();
|
||||
final online = await api.stateStream
|
||||
.firstWhere((s) =>
|
||||
s == SessionState.online || s == SessionState.disconnected)
|
||||
.timeout(const Duration(seconds: 15),
|
||||
onTimeout: () => SessionState.disconnected);
|
||||
if (!mounted) return;
|
||||
if (online == SessionState.online) {
|
||||
showCustomNotification(context, l10n.serverSettingsSaved);
|
||||
} else {
|
||||
showCustomNotification(context, l10n.serverReconnectFailed);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetToDefault(AppLocalizations l10n) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(ServerConfig.prefHostKey);
|
||||
await prefs.remove(ServerConfig.prefPortKey);
|
||||
_hostController.text = ServerConfig.defaultHost;
|
||||
_portController.text = '${ServerConfig.defaultPort}';
|
||||
await api.disconnect();
|
||||
api.connect();
|
||||
final online = await api.stateStream
|
||||
.firstWhere((s) =>
|
||||
s == SessionState.online || s == SessionState.disconnected)
|
||||
.timeout(const Duration(seconds: 15),
|
||||
onTimeout: () => SessionState.disconnected);
|
||||
if (!mounted) return;
|
||||
if (online == SessionState.online) {
|
||||
showCustomNotification(context, l10n.serverSettingsSaved);
|
||||
} else {
|
||||
showCustomNotification(context, l10n.serverReconnectFailed);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostController.dispose();
|
||||
_portController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.serverSettingsTitle,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTextField(
|
||||
controller: _hostController,
|
||||
label: l10n.serverHostLabel,
|
||||
hintText: ServerConfig.defaultHost,
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _portController,
|
||||
label: l10n.serverPortLabel,
|
||||
hintText: '${ServerConfig.defaultPort}',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : () => _apply(l10n),
|
||||
child: Text(l10n.serverApply),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _busy ? null : () => _resetToDefault(l10n),
|
||||
child: Text(l10n.serverUseDefault),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required ColorScheme cs,
|
||||
String? hintText,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: !_busy,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 15,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:komet/core/config/spoof_data.dart';
|
||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
|
||||
class SpoffRedactedScreen extends StatefulWidget {
|
||||
const SpoffRedactedScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpoffRedactedScreen> createState() => _SpoffRedactedScreenState();
|
||||
}
|
||||
|
||||
class _SpoffRedactedScreenState extends State<SpoffRedactedScreen> {
|
||||
final TextEditingController _deviceNameController = TextEditingController();
|
||||
final TextEditingController _osVersionController = TextEditingController();
|
||||
final TextEditingController _resolutionController = TextEditingController();
|
||||
final TextEditingController _deviceIdController = TextEditingController();
|
||||
final TextEditingController _architectureController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final random = Random();
|
||||
|
||||
setState(() {
|
||||
_deviceNameController.text =
|
||||
prefs.getString('spoof_device_name') ??
|
||||
SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)];
|
||||
_osVersionController.text =
|
||||
prefs.getString('spoof_os_version') ??
|
||||
SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)];
|
||||
_resolutionController.text =
|
||||
prefs.getString('spoof_resolution') ??
|
||||
SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)];
|
||||
_deviceIdController.text =
|
||||
prefs.getString('spoof_device_id') ??
|
||||
SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)];
|
||||
_architectureController.text =
|
||||
prefs.getString('spoof_architecture') ??
|
||||
SpoofData.architectures[random.nextInt(
|
||||
SpoofData.architectures.length,
|
||||
)];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('spoof_device_name', _deviceNameController.text);
|
||||
await prefs.setString('spoof_os_version', _osVersionController.text);
|
||||
await prefs.setString('spoof_resolution', _resolutionController.text);
|
||||
await prefs.setString('spoof_device_id', _deviceIdController.text);
|
||||
await prefs.setString('spoof_architecture', _architectureController.text);
|
||||
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Настройки сохранены');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_deviceNameController.dispose();
|
||||
_osVersionController.dispose();
|
||||
_resolutionController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
_architectureController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
iconTheme: IconThemeData(color: cs.onSurface),
|
||||
title: Text(
|
||||
'Подделка спуфа',
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
backgroundColor: cs.surface,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.check, color: cs.primary),
|
||||
onPressed: _saveSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTextField(
|
||||
controller: TextEditingController(text: SpoofData.deviceType),
|
||||
label: 'Тип устройства',
|
||||
cs: cs,
|
||||
readOnly: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _deviceNameController,
|
||||
label: 'Имя устройства',
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _osVersionController,
|
||||
label: 'Версия ОС',
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _resolutionController,
|
||||
label: 'Разрешение экрана',
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: TextEditingController(text: SpoofData.timezone),
|
||||
label: 'Часовой пояс',
|
||||
cs: cs,
|
||||
readOnly: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: TextEditingController(text: SpoofData.locale),
|
||||
label: 'Локаль',
|
||||
cs: cs,
|
||||
readOnly: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _deviceIdController,
|
||||
label: 'ID устройства',
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: TextEditingController(text: SpoofData.appVersion),
|
||||
label: 'Версия приложения',
|
||||
cs: cs,
|
||||
readOnly: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: TextEditingController(text: SpoofData.buildNumber),
|
||||
label: 'Build Number',
|
||||
cs: cs,
|
||||
readOnly: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTextField(
|
||||
controller: _architectureController,
|
||||
label: 'Архитектура',
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required ColorScheme cs,
|
||||
bool readOnly = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
readOnly: readOnly,
|
||||
style: GoogleFonts.inter(
|
||||
color: readOnly ? cs.onSurfaceVariant : cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: readOnly
|
||||
? cs.surfaceContainerHighest
|
||||
: cs.surfaceContainerHigh,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show api;
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../backend/modules/calls.dart';
|
||||
|
||||
class CallsTab extends StatelessWidget {
|
||||
class CallsTab extends StatefulWidget {
|
||||
const CallsTab({super.key});
|
||||
|
||||
@override
|
||||
State<CallsTab> createState() => _CallsTabState();
|
||||
}
|
||||
|
||||
class _CallsTabState extends State<CallsTab> {
|
||||
List<CallLogEntry> _calls = [];
|
||||
bool _isLoading = true;
|
||||
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (p == null) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final callsModule = CallsModule(api);
|
||||
final calls = await callsModule.fetchHistory(p.id, p.id);
|
||||
|
||||
final List<CallLogEntry> grouped = [];
|
||||
for (final call in calls) {
|
||||
if (grouped.isNotEmpty &&
|
||||
grouped.last.peerId == call.peerId &&
|
||||
grouped.last.status == call.status &&
|
||||
_isSameDay(grouped.last.time, call.time)) {
|
||||
final last = grouped.removeLast();
|
||||
grouped.add(
|
||||
CallLogEntry(
|
||||
id: last.id,
|
||||
accountId: last.accountId,
|
||||
peerId: last.peerId,
|
||||
name: last.name,
|
||||
avatarUrl: last.avatarUrl,
|
||||
status: last.status,
|
||||
time: last.time,
|
||||
count: last.count + 1,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
grouped.add(call);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_calls = grouped;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSameDay(int time1, int time2) {
|
||||
if (time1 == 0 || time2 == 0) return false;
|
||||
final d1 = DateTime.fromMillisecondsSinceEpoch(time1);
|
||||
final d2 = DateTime.fromMillisecondsSinceEpoch(time2);
|
||||
return d1.year == d2.year && d1.month == d2.month && d1.day == d2.day;
|
||||
}
|
||||
|
||||
String _formatDate(int timestamp) {
|
||||
if (timestamp == 0) return '';
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final months = [
|
||||
'янв.',
|
||||
'фев.',
|
||||
'мар.',
|
||||
'апр.',
|
||||
'мая',
|
||||
'июн.',
|
||||
'июл.',
|
||||
'авг.',
|
||||
'сен.',
|
||||
'окт.',
|
||||
'ноя.',
|
||||
'дек.',
|
||||
];
|
||||
return '${dt.day} ${months[dt.month - 1]}';
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
|
||||
return Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCallItem(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
CallLogEntry call,
|
||||
) {
|
||||
final bool isMissed = call.status == CallStatus.missed;
|
||||
|
||||
String statusText;
|
||||
IconData statusIcon;
|
||||
switch (call.status) {
|
||||
case CallStatus.missed:
|
||||
statusText = 'Пропущенный';
|
||||
statusIcon = Symbols.phone_missed;
|
||||
break;
|
||||
case CallStatus.canceled:
|
||||
statusText = 'Отменённый';
|
||||
statusIcon = Symbols.phone_disabled;
|
||||
break;
|
||||
case CallStatus.outgoing:
|
||||
statusText = 'Исходящий';
|
||||
statusIcon = Symbols.call_made;
|
||||
break;
|
||||
case CallStatus.incoming:
|
||||
statusText = 'Входящий';
|
||||
statusIcon = Symbols.call_received;
|
||||
break;
|
||||
}
|
||||
|
||||
final String displayName = call.count > 1
|
||||
? '${call.name} (${call.count})'
|
||||
: call.name;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Open call details or initiate call
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.primary.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
call.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildPlaceholderAvatar(cs, call.name),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, call.name),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
color: isMissed ? cs.error : cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(statusIcon, size: 14, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatDate(call.time),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabItem(String label, int index, ColorScheme cs) {
|
||||
final isSelected = _selectedTabIndex == index;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedTabIndex = index;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isSelected ? cs.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? cs.primary : cs.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Text(
|
||||
'Звонки (Заглушка)',
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
final filteredCalls = _selectedTabIndex == 1
|
||||
? _calls.where((c) => c.status == CallStatus.missed).toList()
|
||||
: _calls;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Text(
|
||||
'Звонки',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.link, color: cs.primary, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'Создать групповой звонок',
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTabItem('Все', 0, cs),
|
||||
const SizedBox(width: 8),
|
||||
_buildTabItem('Пропущенные', 1, cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: filteredCalls.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Нет звонков',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 120),
|
||||
itemCount: filteredCalls.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildCallItem(
|
||||
context,
|
||||
cs,
|
||||
filteredCalls[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,112 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'dart:ui';
|
||||
import '../../../main.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../backend/modules/messages.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../models/attachment.dart';
|
||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../widgets/message_bubble.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final int chatId;
|
||||
final String name;
|
||||
final String imageUrl;
|
||||
|
||||
const ChatScreen({super.key, required this.name, required this.imageUrl});
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.name,
|
||||
required this.imageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
class _ChatScreenState extends State<ChatScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _hasText = false;
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isSending = false;
|
||||
late AnimationController _shimmerController;
|
||||
List<CachedMessage> _messages = [];
|
||||
int _myId = 0;
|
||||
CachedChat? chat;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageController.addListener(_onTextChanged);
|
||||
_shimmerController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
)..repeat();
|
||||
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||
_myId = activeProfile?.id ?? 0;
|
||||
ChatsModule.getChat(_myId, widget.chatId).then((value) {
|
||||
chat = value[0];
|
||||
}).catchError((error) {
|
||||
|
||||
});
|
||||
|
||||
final cachedRows = await AppDatabase.loadMessages(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
);
|
||||
if (mounted && cachedRows.isNotEmpty) {
|
||||
setState(() {
|
||||
_messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList();
|
||||
_messages.sort((a, b) => a.time.compareTo(b.time));
|
||||
if (api.state == SessionState.online) {
|
||||
_isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await messagesModule.fetchHistory(_myId, widget.chatId);
|
||||
final updatedRows = await AppDatabase.loadMessages(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_messages = updatedRows
|
||||
.map((r) => CachedMessage.fromDbRow(r))
|
||||
.toList();
|
||||
_messages.sort((a, b) => a.time.compareTo(b.time));
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
_loadForwardedSenderNames();
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching history: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.removeListener(_onTextChanged);
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_shimmerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -38,52 +119,186 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty || _myId == 0) return;
|
||||
|
||||
setState(() {
|
||||
_isSending = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final tempMessage = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: text,
|
||||
time: now,
|
||||
status: 'sending',
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_messages.add(tempMessage);
|
||||
_messageController.clear();
|
||||
_hasText = false;
|
||||
});
|
||||
|
||||
_scrollToBottom();
|
||||
|
||||
await messagesModule.sendMessage(_myId, widget.chatId, text);
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (index != -1) {
|
||||
setState(() {
|
||||
_messages[index] = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: text,
|
||||
time: now,
|
||||
status: 'sent',
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error sending message: $e');
|
||||
} finally {
|
||||
setState(() {
|
||||
_isSending = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadForwardedSenderNames() async {
|
||||
final forwardIds = <int>{};
|
||||
for (final msg in _messages) {
|
||||
if (msg.attachments != null) {
|
||||
for (final a in msg.attachments!) {
|
||||
if (a is ForwardedMessageAttachment) {
|
||||
if (a.originalSenderName == null) {
|
||||
forwardIds.add(a.originalSenderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (forwardIds.isEmpty) return;
|
||||
|
||||
for (final id in forwardIds) {
|
||||
final name = await messagesModule.searchContactById(id);
|
||||
final avatar = ContactCache.getAvatar(id);
|
||||
if (name != null && mounted) {
|
||||
setState(() {
|
||||
for (var i = 0; i < _messages.length; i++) {
|
||||
final msg = _messages[i];
|
||||
if (msg.attachments != null) {
|
||||
final newAttaches = msg.attachments!.map((a) {
|
||||
if (a is ForwardedMessageAttachment &&
|
||||
a.originalSenderId == id &&
|
||||
a.originalSenderName == null) {
|
||||
return ForwardedMessageAttachment(
|
||||
originalSenderId: id,
|
||||
originalSenderName: name,
|
||||
originalSenderAvatar: avatar,
|
||||
originalMessageId: a.originalMessageId,
|
||||
originalTime: a.originalTime,
|
||||
originalText: a.originalText,
|
||||
originalChatId: a.originalChatId,
|
||||
originalAttachments: a.originalAttachments,
|
||||
originalContact: a.originalContact,
|
||||
);
|
||||
}
|
||||
return a;
|
||||
}).toList();
|
||||
_messages[i] = CachedMessage(
|
||||
id: msg.id,
|
||||
accountId: msg.accountId,
|
||||
chatId: msg.chatId,
|
||||
senderId: msg.senderId,
|
||||
text: msg.text,
|
||||
time: msg.time,
|
||||
status: msg.status,
|
||||
payload: msg.payload,
|
||||
attachments: newAttaches,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.minScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
// TODO: Локализация
|
||||
// TODO: Cклонения
|
||||
String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently";
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF1B1B1B).withOpacity(0.8),
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
foregroundColor: cs.onSurface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
iconTheme: IconThemeData(color: cs.onSurface),
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
Symbols.arrow_back,
|
||||
color: Colors.white,
|
||||
weight: 400,
|
||||
),
|
||||
icon: const Icon(Symbols.arrow_back, weight: 400),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: NetworkImage(widget.imageUrl),
|
||||
),
|
||||
if (widget.imageUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: NetworkImage(widget.imageUrl),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'Connecting...',
|
||||
Text(
|
||||
widget.name,
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
status ?? "",
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
@@ -95,46 +310,165 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.call, color: Colors.white, weight: 400),
|
||||
icon: const Icon(Symbols.call, weight: 400),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Symbols.more_vert,
|
||||
color: Colors.white,
|
||||
weight: 400,
|
||||
),
|
||||
icon: const Icon(Symbols.more_vert, weight: 400),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
body: Column(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.network(
|
||||
'https://images.unsplash.com/photo-1579546929518-9e396f3cc809',
|
||||
fit: BoxFit.cover,
|
||||
opacity: const AlwaysStoppedAnimation(0.4),
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading && _messages.isEmpty
|
||||
? _buildShimmerLoading()
|
||||
: _buildMessagesList(),
|
||||
),
|
||||
const Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black87],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(children: [const Spacer(), _buildInputArea(context)]),
|
||||
_buildInputArea(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesList() {
|
||||
if (_messages.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No messages yet',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = _messages[_messages.length - 1 - index];
|
||||
final isMe = message.senderId == _myId;
|
||||
final prevMessage = index < _messages.length - 1
|
||||
? _messages[_messages.length - 2 - index]
|
||||
: null;
|
||||
final nextMessage = index > 0
|
||||
? _messages[_messages.length - index]
|
||||
: null;
|
||||
|
||||
return MessageBubble(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
myId: _myId,
|
||||
prevMessage: prevMessage,
|
||||
nextMessage: nextMessage,
|
||||
chatType: chat!.type,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShimmerLoading() {
|
||||
return AnimatedBuilder(
|
||||
animation: _shimmerController,
|
||||
builder: (context, child) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final placeholder = cs.surfaceContainerHighest;
|
||||
final opacity = 0.3 + (0.4 * _shimmerController.value);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: 8,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
final hasImage = index % 3 == 0;
|
||||
final hasReactions = index % 2 == 0;
|
||||
final width1 = 60.0 + (index * 15 % 50);
|
||||
final width2 = 120.0 + (index * 25 % 80);
|
||||
|
||||
return Opacity(
|
||||
opacity: opacity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: placeholder,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: width1,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: placeholder,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: width2,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: placeholder,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
if (hasImage) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: placeholder,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasReactions) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: List.generate(
|
||||
3,
|
||||
(i) => Container(
|
||||
width: 32,
|
||||
height: 16,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: placeholder,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
@@ -144,52 +478,45 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 54,
|
||||
maxHeight: 180,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1B1B1B).withOpacity(0.9),
|
||||
color: Color.alphaBlend(
|
||||
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
||||
cs.surface,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 0,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.face,
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey,
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -205,7 +532,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Icon(
|
||||
Symbols.attachment,
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
color: mutedIcon,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
@@ -221,15 +548,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
width: 54,
|
||||
height: 54,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2B2B2B),
|
||||
decoration: BoxDecoration(
|
||||
color: _hasText ? cs.primary : cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_hasText ? Symbols.send : Symbols.mic,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
child: GestureDetector(
|
||||
onTap: _hasText ? _sendMessage : null,
|
||||
child: Icon(
|
||||
_hasText ? Symbols.send : Symbols.mic,
|
||||
color: _hasText ? cs.onPrimary : cs.onSurface,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,19 +1,195 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../backend/modules/contacts.dart';
|
||||
|
||||
class ContactsTab extends StatelessWidget {
|
||||
class ContactsTab extends StatefulWidget {
|
||||
const ContactsTab({super.key});
|
||||
|
||||
@override
|
||||
State<ContactsTab> createState() => _ContactsTabState();
|
||||
}
|
||||
|
||||
class _ContactsTabState extends State<ContactsTab> {
|
||||
List<CachedContact> _contacts = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadContacts();
|
||||
}
|
||||
|
||||
Future<void> _loadContacts() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (p == null) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
final contacts = await ContactsModule.getContacts(p.id);
|
||||
// Sort contacts by first name
|
||||
contacts.sort((a, b) => a.firstName.compareTo(b.firstName));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_contacts = contacts;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
|
||||
return Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactItem(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
CachedContact contact,
|
||||
) {
|
||||
final fullName =
|
||||
'${contact.firstName}${contact.lastName != null ? ' ${contact.lastName}' : ''}'
|
||||
.trim();
|
||||
final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Open contact details or chat
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.primary.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
contact.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildPlaceholderAvatar(cs, nameToDisplay),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, nameToDisplay),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
nameToDisplay,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
contact.updateTime > 0
|
||||
? 'Был(а) недавно'
|
||||
: '+${contact.phone}',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Text(
|
||||
'Контакты (Заглушка)',
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Контакты',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.person_add, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.search, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _contacts.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Нет контактов',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 120),
|
||||
itemCount: _contacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = _contacts[index];
|
||||
return _buildContactItem(context, cs, contact);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
|
||||
class DebugMenuScreen extends StatelessWidget {
|
||||
const DebugMenuScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final appState = KometApp.stateOf(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.arrow_back,
|
||||
color: cs.onSurface,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Для разработчиков',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: appState == null
|
||||
? const SizedBox.shrink()
|
||||
: ValueListenableBuilder<bool>(
|
||||
valueListenable: appState.fpsOverlayEnabled,
|
||||
builder: (context, fpsOn, _) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 17,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.speed,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Оверлей FPS',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Показ текущего фреймрейта поверх интерфейса',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: fpsOn,
|
||||
onChanged: (v) {
|
||||
appState.setFpsOverlayEnabled(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 120)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show accountModule;
|
||||
import '../../../backend/modules/account.dart' show SessionInfo;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import 'web_qr_scan_screen.dart';
|
||||
|
||||
class DevicesScreen extends StatefulWidget {
|
||||
const DevicesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DevicesScreen> createState() => _DevicesScreenState();
|
||||
}
|
||||
|
||||
class _DevicesScreenState extends State<DevicesScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
bool _isLoading = true;
|
||||
List<SessionInfo> _sessions = [];
|
||||
final Map<int, Map<String, dynamic>> _ipDetails = {};
|
||||
final Set<int> _loadingIps = {};
|
||||
final Set<int> _expandedSessions = {};
|
||||
late AnimationController _shimmerController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shimmerController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
)..repeat();
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shimmerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
try {
|
||||
final sessions = await accountModule.getSessions();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sessions = sessions;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Ошибка загрузки: $e');
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _showPasteQrDialog() async {
|
||||
final tec = TextEditingController();
|
||||
try {
|
||||
return await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final cs = Theme.of(dialogContext).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
title: Text(
|
||||
'Ссылка из QR',
|
||||
style: GoogleFonts.outfit(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
content: TextField(
|
||||
controller: tec,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Вставьте содержимое QR-кода',
|
||||
),
|
||||
autofocus: true,
|
||||
maxLines: 4,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(
|
||||
'Отмена',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final v = tec.text.trim();
|
||||
Navigator.pop(dialogContext, v.isEmpty ? null : v);
|
||||
},
|
||||
child: const Text('Подтвердить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
tec.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirmQrWebLoginSheet() async {
|
||||
final agreed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
final cs = Theme.of(sheetContext).colorScheme;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Вход по QR',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Вы точно хотите войти в аккаунт через веб или приложение MAX на компьютере?',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.35,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(sheetContext).pop(false),
|
||||
child: Text(
|
||||
'Отмена',
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(sheetContext).pop(true),
|
||||
child: const Text('Войти'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return agreed ?? false;
|
||||
}
|
||||
|
||||
Future<void> _startWebQrAuth() async {
|
||||
final canScan = !kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
|
||||
final String? qr;
|
||||
if (canScan) {
|
||||
qr = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const WebQrScanScreen()),
|
||||
);
|
||||
} else {
|
||||
qr = await _showPasteQrDialog();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (qr == null || qr.trim().isEmpty) return;
|
||||
|
||||
final confirmed = await _confirmQrWebLoginSheet();
|
||||
if (!mounted) return;
|
||||
if (!confirmed) return;
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Center(
|
||||
child: Card(
|
||||
color: cs.surfaceContainerHigh,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(28),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await accountModule.authorizeWebQrLogin(qr.trim());
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
showCustomNotification(context, 'Вход по QR подтверждён');
|
||||
_loadSessions();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _terminateOthers() async {
|
||||
try {
|
||||
await accountModule.terminateOtherSessions();
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Все сессии завершены');
|
||||
_loadSessions();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _lookupIp(int id, String location) async {
|
||||
if (_ipDetails.containsKey(id)) {
|
||||
setState(() => _expandedSessions.add(id));
|
||||
return;
|
||||
}
|
||||
|
||||
final reg = RegExp(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b');
|
||||
final match = reg.firstMatch(location);
|
||||
if (match == null) return;
|
||||
final ip = match.group(0)!;
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _loadingIps.add(id));
|
||||
}
|
||||
|
||||
try {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 5);
|
||||
final request = await client.getUrl(
|
||||
Uri.parse(
|
||||
'http://ip-api.com/json/$ip?fields=status,message,country,city,isp,as,mobile,proxy,timezone',
|
||||
),
|
||||
);
|
||||
final response = await request.close();
|
||||
if (response.statusCode == 200) {
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final data = jsonDecode(body);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_ipDetails[id] = data;
|
||||
_expandedSessions.add(id);
|
||||
_loadingIps.remove(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _loadingIps.remove(id));
|
||||
showCustomNotification(context, 'Ошибка IP: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(int timestamp) {
|
||||
if (timestamp == 0) return '';
|
||||
final now = DateTime.now();
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
|
||||
if (now.year == date.year &&
|
||||
now.month == date.month &&
|
||||
now.day == date.day) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
final months = [
|
||||
'янв.',
|
||||
'февр.',
|
||||
'мар.',
|
||||
'апр.',
|
||||
'мая',
|
||||
'июня',
|
||||
'июля',
|
||||
'авг.',
|
||||
'сент.',
|
||||
'окт.',
|
||||
'нояб.',
|
||||
'дек.',
|
||||
];
|
||||
|
||||
if (now.year == date.year) {
|
||||
return '${date.day} ${months[date.month - 1]}';
|
||||
}
|
||||
|
||||
return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.surface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Symbols.chevron_left, size: 28),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
'Устройства',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildPromoCard(context, cs),
|
||||
const SizedBox(height: 12),
|
||||
_buildDevicesList(context, cs),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromoCard(BuildContext context, ColorScheme cs) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.onSurface.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(Symbols.devices, color: cs.onSurface, size: 28),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Устройства в KOMET',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Кто имеет доступ к вашему аккаунту?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _startWebQrAuth,
|
||||
icon: const Icon(Symbols.qr_code_scanner, size: 22),
|
||||
label: Text(
|
||||
'Сканировать QR',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDevicesList(BuildContext context, ColorScheme cs) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_isLoading)
|
||||
...List.generate(5, (index) => _buildShimmerItem(cs))
|
||||
else
|
||||
..._sessions.map(
|
||||
(session) => _buildDeviceItem(
|
||||
context,
|
||||
cs,
|
||||
id: session.uniqueId,
|
||||
title: session.client + (session.current ? ' (текущая)' : ''),
|
||||
platform: session.info,
|
||||
location: session.location,
|
||||
status: session.current ? 'В сети' : null,
|
||||
time: session.current ? null : _formatTime(session.time),
|
||||
isOnline: session.current,
|
||||
),
|
||||
),
|
||||
if (!_isLoading) ...[
|
||||
const SizedBox(height: 12),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: cs.onSurface.withValues(alpha: 0.05),
|
||||
indent: 20,
|
||||
endIndent: 20,
|
||||
),
|
||||
InkWell(
|
||||
onTap: _terminateOthers,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(24),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 20,
|
||||
horizontal: 20,
|
||||
),
|
||||
child: Text(
|
||||
'Завершить все сессии, кроме текущей',
|
||||
style: TextStyle(
|
||||
color: cs.error.withValues(alpha: 0.8),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShimmerItem(ColorScheme cs) {
|
||||
return AnimatedBuilder(
|
||||
animation: _shimmerController,
|
||||
builder: (context, child) {
|
||||
final opacity = 0.3 + 0.2 * sin(_shimmerController.value * pi * 2);
|
||||
return Opacity(
|
||||
opacity: opacity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 140,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceItem(
|
||||
BuildContext context,
|
||||
ColorScheme cs, {
|
||||
required int id,
|
||||
required String title,
|
||||
required String platform,
|
||||
required String location,
|
||||
String? status,
|
||||
String? time,
|
||||
bool isOnline = false,
|
||||
}) {
|
||||
final details = _ipDetails[id];
|
||||
final isLoading = _loadingIps.contains(id);
|
||||
final isExpanded = _expandedSessions.contains(id);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
platform,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
location,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (status != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: isOnline ? Colors.greenAccent : cs.outline,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
status,
|
||||
style: TextStyle(
|
||||
color: isOnline
|
||||
? Colors.greenAccent
|
||||
: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (time != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
time,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!isExpanded)
|
||||
InkWell(
|
||||
onTap: isLoading ? null : () => _lookupIp(id, location),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: cs.onSurfaceVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Symbols.add_circle,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutQuart,
|
||||
child: isExpanded && details != null
|
||||
? Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurface.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
cs,
|
||||
Symbols.location_city,
|
||||
'${details['city'] ?? 'Unknown'}, ${details['country'] ?? ''}',
|
||||
),
|
||||
_buildDetailRow(
|
||||
cs,
|
||||
Symbols.dns,
|
||||
details['isp'] ?? 'Unknown',
|
||||
),
|
||||
_buildDetailRow(
|
||||
cs,
|
||||
Symbols.public,
|
||||
details['as'] ?? 'Unknown',
|
||||
),
|
||||
if (details['mobile'] == true)
|
||||
_buildDetailRow(
|
||||
cs,
|
||||
Symbols.stay_current_portrait,
|
||||
'Мобильная сеть',
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
if (details['proxy'] == true)
|
||||
_buildDetailRow(
|
||||
cs,
|
||||
Symbols.vpn_lock,
|
||||
'Обнаружен прокси/VPN',
|
||||
color: Colors.orangeAccent,
|
||||
),
|
||||
_buildDetailRow(
|
||||
cs,
|
||||
Symbols.schedule,
|
||||
details['timezone'] ?? 'Unknown',
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _expandedSessions.remove(id)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Symbols.do_not_disturb_on,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: double.infinity, height: 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String text, {
|
||||
Color? color,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,88 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../auth/proxy_settings_sheet.dart';
|
||||
import 'debug_menu_screen.dart';
|
||||
import 'devices_screen.dart';
|
||||
import 'security_screen.dart';
|
||||
import 'spoof_screen.dart';
|
||||
|
||||
class SettingsTab extends StatelessWidget {
|
||||
class SettingsTab extends StatefulWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsTab> createState() => _SettingsTabState();
|
||||
}
|
||||
|
||||
class _SettingsTabState extends State<SettingsTab> {
|
||||
ProfileData? _profile;
|
||||
bool _isPhoneVisible = false;
|
||||
String? _appVersionLabel;
|
||||
bool _debugMenuVisible = false;
|
||||
int _versionSecretTapCount = 0;
|
||||
Timer? _versionSecretTapResetTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
_loadAppVersion();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_versionSecretTapResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scheduleVersionSecretTapReset() {
|
||||
_versionSecretTapResetTimer?.cancel();
|
||||
_versionSecretTapResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (mounted) setState(() => _versionSecretTapCount = 0);
|
||||
});
|
||||
}
|
||||
|
||||
void _onVersionLabelTap() {
|
||||
_scheduleVersionSecretTapReset();
|
||||
setState(() {
|
||||
_versionSecretTapCount++;
|
||||
if (_versionSecretTapCount >= 7) {
|
||||
_versionSecretTapCount = 0;
|
||||
_versionSecretTapResetTimer?.cancel();
|
||||
_debugMenuVisible = !_debugMenuVisible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (mounted) setState(() => _profile = p);
|
||||
}
|
||||
|
||||
Future<void> _loadAppVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_appVersionLabel = 'Версия ${info.version} (${info.buildNumber})';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
if (_profile == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final String fullName =
|
||||
'${_profile!.firstName}${_profile!.lastName != null ? ' ${_profile!.lastName}' : ''}';
|
||||
final String phone = '+${_profile!.phone}';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
@@ -14,7 +90,9 @@ class SettingsTab extends StatelessWidget {
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildHeader(context, cs)),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildHeader(context, cs, fullName, phone),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
@@ -25,7 +103,7 @@ class SettingsTab extends StatelessWidget {
|
||||
_SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'),
|
||||
_SettingsItem(
|
||||
icon: Symbols.language,
|
||||
label: 'Войти в сферум',
|
||||
label: 'Войти в Сферум',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -37,17 +115,158 @@ class SettingsTab extends StatelessWidget {
|
||||
child: _buildSection(
|
||||
context,
|
||||
cs,
|
||||
items: const [
|
||||
_SettingsItem(
|
||||
items: [
|
||||
const _SettingsItem(
|
||||
icon: Symbols.notifications_active,
|
||||
label: 'Уведомления и звук',
|
||||
),
|
||||
_SettingsItem(icon: Symbols.lock, label: 'Безопасность'),
|
||||
_SettingsItem(icon: Symbols.devices, label: 'Устройства'),
|
||||
_SettingsItem(
|
||||
icon: Symbols.vpn_lock,
|
||||
label: 'Прокси',
|
||||
onTap: () {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ProxySettingsSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.shield_lock,
|
||||
label: AppLocalizations.of(context)!.profileMenuSpoof,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SpoofScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.lock,
|
||||
label: 'Безопасность',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SecurityScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.devices,
|
||||
label: 'Устройства',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DevicesScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 340),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
return ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: animation.value.clamp(0.0, 1.0),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
...previousChildren,
|
||||
?currentChild,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: _debugMenuVisible
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('developers_settings_row'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: _buildSection(
|
||||
context,
|
||||
cs,
|
||||
items: [
|
||||
_SettingsItem(
|
||||
icon: Symbols.construction,
|
||||
label: 'Для разработчиков',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const DebugMenuScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('developers_settings_hidden'),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_appVersionLabel != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 28, 16, 12),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _onVersionLabelTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
_appVersionLabel!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.75),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 120)),
|
||||
],
|
||||
),
|
||||
@@ -55,7 +274,12 @@ class SettingsTab extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, ColorScheme cs) {
|
||||
Widget _buildHeader(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
String name,
|
||||
String phone,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 20),
|
||||
child: Column(
|
||||
@@ -95,23 +319,19 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
'https://i.pravatar.cc/150?u=ilya',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => CircleAvatar(
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Icon(
|
||||
Symbols.person,
|
||||
color: cs.onPrimaryContainer,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
_profile!.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildPlaceholderAvatar(cs, name),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, name),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Илья Беларуских',
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 20,
|
||||
@@ -119,39 +339,70 @@ class SettingsTab extends StatelessWidget {
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'@everrnyan',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: _PhoneSpoiler(
|
||||
text: phone,
|
||||
isVisible: _isPhoneVisible,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isPhoneVisible ? Symbols.visibility : Symbols.visibility_off,
|
||||
size: 14,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
|
||||
return Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(
|
||||
BuildContext context,
|
||||
ColorScheme cs, {
|
||||
required List<_SettingsItem> items,
|
||||
}) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final isLast = index == items.length - 1;
|
||||
return _buildSettingsRow(context, cs, item, isLast: isLast);
|
||||
}),
|
||||
),
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final isLast = index == items.length - 1;
|
||||
return _buildSettingsRow(context, cs, item, isLast: isLast);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -167,7 +418,10 @@ class SettingsTab extends StatelessWidget {
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
onTap: item.onTap ?? () {},
|
||||
borderRadius: isLast
|
||||
? const BorderRadius.vertical(bottom: Radius.circular(20))
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
|
||||
child: Row(
|
||||
@@ -217,6 +471,97 @@ class SettingsTab extends StatelessWidget {
|
||||
class _SettingsItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SettingsItem({required this.icon, required this.label});
|
||||
const _SettingsItem({required this.icon, required this.label, this.onTap});
|
||||
}
|
||||
|
||||
class _PhoneSpoiler extends StatefulWidget {
|
||||
final String text;
|
||||
final bool isVisible;
|
||||
final TextStyle style;
|
||||
|
||||
const _PhoneSpoiler({
|
||||
required this.text,
|
||||
required this.isVisible,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PhoneSpoiler> createState() => _PhoneSpoilerState();
|
||||
}
|
||||
|
||||
class _PhoneSpoilerState extends State<_PhoneSpoiler>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
crossFadeState: widget.isVisible
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: SizedBox(
|
||||
child: CustomPaint(
|
||||
size: const Size(110, 16),
|
||||
painter: _SpoilerPainter(_controller, widget.style.color!),
|
||||
),
|
||||
),
|
||||
secondChild: Text(widget.text, style: widget.style),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpoilerPainter extends CustomPainter {
|
||||
final Animation<double> animation;
|
||||
final Color color;
|
||||
|
||||
_SpoilerPainter(this.animation, this.color) : super(repaint: animation);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: 0.15)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Draw the background
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(0, 0, size.width, size.height),
|
||||
const Radius.circular(4),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
|
||||
// Draw "noisy" particles
|
||||
final particlePaint = Paint()..style = PaintingStyle.fill;
|
||||
|
||||
// Simple noise effect with dots using animation value for movement
|
||||
for (int i = 0; i < 60; i++) {
|
||||
double dx = (i * 17.5 + animation.value * 20) % size.width;
|
||||
double dy = (i * 13.7 + animation.value * 15) % size.height;
|
||||
double opacity = (0.2 + 0.3 * (i % 5) / 5.0).clamp(0.0, 1.0);
|
||||
particlePaint.color = color.withValues(alpha: opacity);
|
||||
canvas.drawCircle(Offset(dx, dy), 1.2, particlePaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SpoilerPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../core/config/device_presets.dart';
|
||||
import '../../../core/storage/spoofing_service.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
|
||||
enum SpoofingMethod { partial, full }
|
||||
|
||||
class SpoofScreen extends StatefulWidget {
|
||||
const SpoofScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpoofScreen> createState() => _SpoofScreenState();
|
||||
}
|
||||
|
||||
class _SpoofScreenState extends State<SpoofScreen> {
|
||||
static const String _hardcodedVersion = SpoofingService.hardcodedAppVersion;
|
||||
static const int _hardcodedBuildNumber = SpoofingService.hardcodedBuildNumber;
|
||||
|
||||
final _random = Random();
|
||||
final _deviceNameController = TextEditingController();
|
||||
final _osVersionController = TextEditingController();
|
||||
final _screenController = TextEditingController();
|
||||
final _timezoneController = TextEditingController();
|
||||
final _localeController = TextEditingController();
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _appVersionController = TextEditingController();
|
||||
final _buildNumberController = TextEditingController();
|
||||
|
||||
String _selectedDeviceType = 'ANDROID';
|
||||
String _selectedArch = 'arm64-v8a';
|
||||
SpoofingMethod _selectedMethod = SpoofingMethod.partial;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialData();
|
||||
}
|
||||
|
||||
String _generateDeviceId() {
|
||||
final bytes = List<int>.generate(8, (_) => _random.nextInt(256));
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
setState(() => _isLoading = true);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isSpoofingEnabled = prefs.getBool('spoofing_enabled') ?? false;
|
||||
|
||||
if (isSpoofingEnabled) {
|
||||
_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') ?? _hardcodedVersion;
|
||||
_selectedArch = prefs.getString('spoof_arch') ?? 'arm64-v8a';
|
||||
_buildNumberController.text =
|
||||
prefs.getInt('spoof_buildnumber')?.toString() ??
|
||||
'$_hardcodedBuildNumber';
|
||||
|
||||
String savedType = prefs.getString('spoof_devicetype') ?? 'ANDROID';
|
||||
if (savedType == 'WEB') savedType = 'ANDROID';
|
||||
_selectedDeviceType = savedType;
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
} else {
|
||||
await _loadDeviceData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDeviceData() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final pixelRatio = View.of(context).devicePixelRatio;
|
||||
final size = View.of(context).physicalSize;
|
||||
|
||||
_appVersionController.text = _hardcodedVersion;
|
||||
_localeController.text = Platform.localeName.split('_').first;
|
||||
|
||||
final dpi = (160 * pixelRatio).round();
|
||||
String densityBucket;
|
||||
if (dpi >= 560) {
|
||||
densityBucket = 'xxxhdpi';
|
||||
} else if (dpi >= 380) {
|
||||
densityBucket = 'xxhdpi';
|
||||
} else if (dpi >= 280) {
|
||||
densityBucket = 'xhdpi';
|
||||
} else if (dpi >= 200) {
|
||||
densityBucket = 'hdpi';
|
||||
} else if (dpi >= 140) {
|
||||
densityBucket = 'mdpi';
|
||||
} else {
|
||||
densityBucket = 'ldpi';
|
||||
}
|
||||
_screenController.text =
|
||||
'$densityBucket ${dpi}dpi ${size.width.round()}x${size.height.round()}';
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var realDeviceId = prefs.getString('real_device_id');
|
||||
if (realDeviceId == null || realDeviceId.isEmpty) {
|
||||
realDeviceId = _generateDeviceId();
|
||||
await prefs.setString('real_device_id', realDeviceId);
|
||||
}
|
||||
_deviceIdController.text = realDeviceId;
|
||||
|
||||
try {
|
||||
final timezoneInfo = await FlutterTimezone.getLocalTimezone();
|
||||
_timezoneController.text = timezoneInfo.identifier;
|
||||
} catch (_) {
|
||||
_timezoneController.text = 'Europe/Moscow';
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
_deviceNameController.text =
|
||||
'${androidInfo.manufacturer} ${androidInfo.model}';
|
||||
_osVersionController.text = 'Android ${androidInfo.version.release}';
|
||||
_selectedArch = androidInfo.supportedAbis.isNotEmpty
|
||||
? androidInfo.supportedAbis.first
|
||||
: 'arm64-v8a';
|
||||
_buildNumberController.text = '$_hardcodedBuildNumber';
|
||||
} else {
|
||||
await _applyGeneratedData();
|
||||
}
|
||||
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _applyGeneratedData() async {
|
||||
final filteredPresets = devicePresets
|
||||
.where(
|
||||
(p) => p.deviceType != 'WEB' && p.deviceType == _selectedDeviceType,
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (filteredPresets.isEmpty) return;
|
||||
|
||||
final preset = filteredPresets[_random.nextInt(filteredPresets.length)];
|
||||
await _applyPreset(preset);
|
||||
}
|
||||
|
||||
Future<void> _applyPreset(DevicePreset preset) async {
|
||||
setState(() {
|
||||
_deviceNameController.text = preset.deviceName;
|
||||
_osVersionController.text = preset.osVersion;
|
||||
_screenController.text = preset.screen;
|
||||
_appVersionController.text = _hardcodedVersion;
|
||||
_deviceIdController.text = _generateDeviceId();
|
||||
|
||||
_selectedArch = 'arm64-v8a';
|
||||
_buildNumberController.text = '$_hardcodedBuildNumber';
|
||||
|
||||
if (_selectedMethod == SpoofingMethod.full) {
|
||||
_timezoneController.text = preset.timezone;
|
||||
_localeController.text = preset.locale.split(RegExp(r'[-_]')).first;
|
||||
}
|
||||
});
|
||||
|
||||
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<void> _saveSpoofingSettings() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final oldValues = {
|
||||
'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') ?? 'ANDROID',
|
||||
'arch': prefs.getString('spoof_arch') ?? '',
|
||||
};
|
||||
|
||||
final newValues = {
|
||||
'device_name': _deviceNameController.text,
|
||||
'os_version': _osVersionController.text,
|
||||
'screen': _screenController.text,
|
||||
'timezone': _timezoneController.text,
|
||||
'locale': _localeController.text,
|
||||
'device_id': _deviceIdController.text,
|
||||
'device_type': _selectedDeviceType,
|
||||
'arch': _selectedArch,
|
||||
};
|
||||
|
||||
bool otherDataChanged = false;
|
||||
for (final key in oldValues.keys) {
|
||||
if (oldValues[key] != newValues[key]) {
|
||||
otherDataChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!otherDataChanged) {
|
||||
await _saveAllData(prefs);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.spoofDialogApplyTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.spoofDialogApplyContent),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.spoofDialogApplyWarning,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop('cancel'),
|
||||
child: Text(l10n.spoofDialogApplyDeny),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop('relogin'),
|
||||
child: Text(l10n.spoofDialogReloginConfirm),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop('apply'),
|
||||
child: Text(l10n.spoofDialogApplyConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted || confirmed == null) return;
|
||||
|
||||
if (confirmed == 'relogin') {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _saveAllData(prefs);
|
||||
await api.disconnect();
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await TokenStorage.deleteToken(accountId);
|
||||
}
|
||||
await prefs.setBool('spoofing_enabled', true);
|
||||
await api.connect();
|
||||
if (mounted) {
|
||||
final navState = KometApp.navigatorKey.currentState;
|
||||
if (navState != null) {
|
||||
await navState.pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirmed != 'apply') return;
|
||||
|
||||
await _saveAllData(prefs);
|
||||
|
||||
try {
|
||||
await api.disconnect();
|
||||
await api.connect();
|
||||
if (mounted && Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.spoofErrorApplyFailed(e.toString()),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAllData(SharedPreferences prefs) async {
|
||||
await prefs.setBool('spoofing_enabled', true);
|
||||
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_arch', _selectedArch);
|
||||
}
|
||||
|
||||
void _generateNewDeviceId() {
|
||||
setState(() {
|
||||
_deviceIdController.text = _generateDeviceId();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_deviceNameController.dispose();
|
||||
_osVersionController.dispose();
|
||||
_screenController.dispose();
|
||||
_timezoneController.dispose();
|
||||
_localeController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
_appVersionController.dispose();
|
||||
_buildNumberController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.spoofScreenTitle), 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),
|
||||
_buildDeviceTypeCard(),
|
||||
const SizedBox(height: 24),
|
||||
_buildMainDataCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildRegionalDataCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildIdentifiersCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: _buildFloatingActionButtons(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.secondaryContainer.withValues(alpha: 0.5),
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.touch_app,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.spoofInfoHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpoofingMethodCard() {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
Widget descriptionWidget;
|
||||
|
||||
if (_selectedMethod == SpoofingMethod.partial) {
|
||||
descriptionWidget = _buildDescriptionTile(
|
||||
icon: Icons.check_circle_outline,
|
||||
color: Colors.green.shade700,
|
||||
text: l10n.spoofMethodPartialDescription,
|
||||
);
|
||||
} else {
|
||||
descriptionWidget = _buildDescriptionTile(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: theme.colorScheme.error,
|
||||
text: l10n.spoofMethodFullDescription,
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.spoofMethodTitle, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<SpoofingMethod>(
|
||||
style: SegmentedButton.styleFrom(shape: const StadiumBorder()),
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: SpoofingMethod.partial,
|
||||
label: Text(l10n.spoofMethodPartial),
|
||||
icon: const Icon(Icons.security_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: SpoofingMethod.full,
|
||||
label: Text(l10n.spoofMethodFull),
|
||||
icon: const Icon(Icons.public_outlined),
|
||||
),
|
||||
],
|
||||
selected: {_selectedMethod},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _selectedMethod = s.first),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
descriptionWidget,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceTypeCard() {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.spoofDeviceTypeTitle, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
_buildDescriptionTile(
|
||||
icon: Icons.info_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
text: l10n.spoofDeviceTypeDescription,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_buildDisabledChip('ANDROID', Icons.android_outlined, theme),
|
||||
const SizedBox(width: 8),
|
||||
_buildDisabledChip('iOS', Icons.phone_iphone_outlined, theme),
|
||||
const SizedBox(width: 8),
|
||||
_buildDisabledChip(
|
||||
'Desktop',
|
||||
Icons.desktop_windows_outlined,
|
||||
theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDisabledChip(String label, IconData icon, ThemeData theme) {
|
||||
return Chip(
|
||||
label: Text(label),
|
||||
avatar: Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
);
|
||||
}
|
||||
|
||||
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 _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() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(context, l10n.spoofMainSectionTitle),
|
||||
TextField(
|
||||
controller: _deviceNameController,
|
||||
decoration: _inputDecoration(
|
||||
l10n.spoofFieldDeviceName,
|
||||
Icons.smartphone_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _osVersionController,
|
||||
decoration: _inputDecoration(
|
||||
l10n.spoofFieldOsVersion,
|
||||
Icons.layers_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRegionalDataCard() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(context, l10n.spoofRegionalSectionTitle),
|
||||
TextField(
|
||||
controller: _screenController,
|
||||
decoration: _inputDecoration(
|
||||
l10n.spoofFieldScreen,
|
||||
Icons.fullscreen_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _timezoneController,
|
||||
enabled: _selectedMethod == SpoofingMethod.full,
|
||||
decoration: _inputDecoration(
|
||||
l10n.spoofFieldTimezone,
|
||||
Icons.public_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _localeController,
|
||||
enabled: _selectedMethod == SpoofingMethod.full,
|
||||
decoration: _inputDecoration(
|
||||
l10n.spoofFieldLocale,
|
||||
Icons.language_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIdentifiersCard() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(context, l10n.spoofIdentifiersSectionTitle),
|
||||
_buildDescriptionTile(
|
||||
icon: Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
text: l10n.spoofIdentifiersDescription,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _deviceIdController,
|
||||
decoration:
|
||||
_inputDecoration(
|
||||
l10n.spoofFieldDeviceId,
|
||||
Icons.tag_outlined,
|
||||
).copyWith(
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.autorenew_outlined),
|
||||
tooltip: l10n.spoofRegenerateIdTooltip,
|
||||
onPressed: _generateNewDeviceId,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _appVersionController,
|
||||
enabled: false,
|
||||
decoration: _inputDecoration(
|
||||
l10n.spoofFieldAppVersion,
|
||||
Icons.info_outline_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _buildNumberController,
|
||||
enabled: false,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: _inputDecoration(
|
||||
l10n.spoofFieldBuildNumber,
|
||||
Icons.numbers_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
l10n.spoofFieldArchitecture,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildChipSelector<String>(
|
||||
options: const [
|
||||
_ChipOption('arm64-v8a', 'arm64-v8a', Icons.memory_outlined),
|
||||
_ChipOption(
|
||||
'armeabi-v7a',
|
||||
'armeabi-v7a',
|
||||
Icons.memory_outlined,
|
||||
),
|
||||
_ChipOption('x86', 'x86', Icons.memory_outlined),
|
||||
_ChipOption('x86_64', 'x86_64', Icons.memory_outlined),
|
||||
_ChipOption('arm64', 'arm64', Icons.memory_outlined),
|
||||
],
|
||||
selected: _selectedArch,
|
||||
onSelected: (value) => setState(() => _selectedArch = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _buildChipSelector<T>({
|
||||
required List<_ChipOption<T>> options,
|
||||
required T selected,
|
||||
required ValueChanged<T> onSelected,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: options.map((opt) {
|
||||
final isSelected = opt.value == selected;
|
||||
return ChoiceChip(
|
||||
label: Text(opt.label),
|
||||
avatar: isSelected
|
||||
? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer)
|
||||
: (opt.icon != null
|
||||
? Icon(opt.icon, size: 18, color: cs.onSurfaceVariant)
|
||||
: null),
|
||||
selected: isSelected,
|
||||
showCheckmark: false,
|
||||
onSelected: (_) => onSelected(opt.value),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected ? cs.onSecondaryContainer : cs.onSurface,
|
||||
),
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
selectedColor: cs.secondaryContainer,
|
||||
side: BorderSide(
|
||||
color: isSelected ? Colors.transparent : cs.outlineVariant,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingActionButtons() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _applyGeneratedData,
|
||||
onLongPress: _loadDeviceData,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Text(l10n.spoofButtonGenerate),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: FilledButton(
|
||||
onPressed: _saveSpoofingSettings,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.save_alt_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.spoofButtonApply),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChipOption<T> {
|
||||
final T value;
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
|
||||
const _ChipOption(this.value, this.label, [this.icon]);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
class WebQrScanScreen extends StatefulWidget {
|
||||
const WebQrScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WebQrScanScreen> createState() => _WebQrScanScreenState();
|
||||
}
|
||||
|
||||
class _WebQrScanScreenState extends State<WebQrScanScreen> {
|
||||
final MobileScannerController _controller = MobileScannerController();
|
||||
static const _settleAfterDetect = Duration(milliseconds: 720);
|
||||
|
||||
bool _handled = false;
|
||||
String? _armedRaw;
|
||||
Timer? _completeTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_completeTimer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _completeScan() {
|
||||
if (!mounted || _handled) return;
|
||||
final value = _armedRaw;
|
||||
if (value == null || value.isEmpty) return;
|
||||
_handled = true;
|
||||
_completeTimer?.cancel();
|
||||
_completeTimer = null;
|
||||
unawaited(_controller.stop());
|
||||
if (mounted) Navigator.of(context).pop<String>(value);
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_handled) return;
|
||||
final barcodes = capture.barcodes;
|
||||
if (barcodes.isEmpty) return;
|
||||
final raw = barcodes.first.rawValue;
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
if (_armedRaw != raw) {
|
||||
_armedRaw = raw;
|
||||
_completeTimer?.cancel();
|
||||
_completeTimer = Timer(_settleAfterDetect, _completeScan);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Symbols.chevron_left, size: 28),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
'QR для веба и ПК',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, _) {
|
||||
final on = state.torchState == TorchState.on;
|
||||
return Icon(
|
||||
on ? Symbols.flash_on : Symbols.flash_off,
|
||||
color: Colors.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
onPressed: () => _controller.toggleTorch(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final layoutSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
error.errorDetails?.message ?? 'Камера недоступна',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_TelegramStyleFinderOverlay(
|
||||
layoutSize: layoutSize,
|
||||
controller: _controller,
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Наведите камеру на QR-код на экране компьютера',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 8,
|
||||
color: Colors.black54,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TelegramStyleFinderOverlay extends StatefulWidget {
|
||||
const _TelegramStyleFinderOverlay({
|
||||
required this.layoutSize,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final Size layoutSize;
|
||||
final MobileScannerController controller;
|
||||
|
||||
@override
|
||||
State<_TelegramStyleFinderOverlay> createState() =>
|
||||
_TelegramStyleFinderOverlayState();
|
||||
}
|
||||
|
||||
class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _animDuration = Duration(milliseconds: 320);
|
||||
static const _snapPx = 14.0;
|
||||
static const _frameCornerRadius = 16.0;
|
||||
static const _qrBoundsPadding = 14.0;
|
||||
|
||||
late AnimationController _ac;
|
||||
late CurvedAnimation _curved;
|
||||
Rect _fromR = Rect.zero;
|
||||
Rect _toR = Rect.zero;
|
||||
StreamSubscription<BarcodeCapture>? _barcodeSub;
|
||||
bool _qrInView = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final d = _defaultFinderRect(widget.layoutSize);
|
||||
_fromR = d;
|
||||
_toR = d;
|
||||
_ac = AnimationController(vsync: this, duration: _animDuration);
|
||||
_curved = CurvedAnimation(parent: _ac, curve: Curves.easeOutCubic);
|
||||
_ac.addListener(() => setState(() {}));
|
||||
_barcodeSub = widget.controller.barcodes.listen(_onBarcodeFrame);
|
||||
widget.controller.addListener(_onControllerUpdate);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _TelegramStyleFinderOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.layoutSize != widget.layoutSize) {
|
||||
final d = _defaultFinderRect(widget.layoutSize);
|
||||
_fromR = d;
|
||||
_toR = d;
|
||||
_ac.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerUpdate);
|
||||
unawaited(_barcodeSub?.cancel());
|
||||
_curved.dispose();
|
||||
_ac.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerUpdate() {
|
||||
final v = widget.controller.value;
|
||||
if (!v.isInitialized || !v.isRunning || v.error != null) {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _onBarcodeFrame(BarcodeCapture cap) {
|
||||
if (!mounted) return;
|
||||
final v = widget.controller.value;
|
||||
if (!v.isInitialized || !v.isRunning || v.error != null) return;
|
||||
|
||||
final defaultRect = _defaultFinderRect(widget.layoutSize);
|
||||
final mapped = _targetFinderRectFromCapture(cap, v);
|
||||
final inView = mapped != null;
|
||||
if (inView != _qrInView) {
|
||||
setState(() => _qrInView = inView);
|
||||
}
|
||||
final next = mapped ?? defaultRect;
|
||||
if (_rectClose(_toR, next) && _ac.isCompleted) {
|
||||
return;
|
||||
}
|
||||
_animateTo(next);
|
||||
}
|
||||
|
||||
Rect _defaultFinderRect(Size s) {
|
||||
final side = math.min(s.width, s.height) * 0.68;
|
||||
final left = (s.width - side) / 2;
|
||||
final top = (s.height - side) / 2 - s.height * 0.06;
|
||||
return Rect.fromLTWH(left, top, side, side);
|
||||
}
|
||||
|
||||
bool _rectClose(Rect a, Rect b) {
|
||||
return (a.left - b.left).abs() < _snapPx &&
|
||||
(a.top - b.top).abs() < _snapPx &&
|
||||
(a.width - b.width).abs() < _snapPx &&
|
||||
(a.height - b.height).abs() < _snapPx;
|
||||
}
|
||||
|
||||
Rect _interpolatedFinderRect() {
|
||||
if (_fromR.isEmpty || _toR.isEmpty) {
|
||||
return _defaultFinderRect(widget.layoutSize);
|
||||
}
|
||||
return Rect.lerp(_fromR, _toR, _curved.value)!;
|
||||
}
|
||||
|
||||
void _animateTo(Rect target) {
|
||||
if (_rectClose(_toR, target) && _ac.isCompleted) {
|
||||
return;
|
||||
}
|
||||
double t;
|
||||
if (_ac.status == AnimationStatus.completed) {
|
||||
t = 1.0;
|
||||
} else if (_ac.status == AnimationStatus.dismissed) {
|
||||
t = 0.0;
|
||||
} else {
|
||||
t = _curved.value;
|
||||
}
|
||||
_fromR = Rect.lerp(_fromR, _toR, t)!;
|
||||
_toR = target;
|
||||
_ac.forward(from: 0);
|
||||
}
|
||||
|
||||
Rect? _targetFinderRectFromCapture(
|
||||
BarcodeCapture? cap,
|
||||
MobileScannerState scannerState,
|
||||
) {
|
||||
if (cap == null || cap.barcodes.isEmpty || widget.layoutSize.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final b = cap.barcodes.first;
|
||||
if (b.corners.length < 4) return null;
|
||||
|
||||
final refSize = !cap.size.isEmpty
|
||||
? cap.size
|
||||
: (!scannerState.size.isEmpty ? scannerState.size : Size.zero);
|
||||
if (refSize.isEmpty) return null;
|
||||
|
||||
final mapped = _mapBarcodeCornersToLayout(
|
||||
b.corners.take(4).toList(),
|
||||
refSize,
|
||||
widget.layoutSize,
|
||||
scannerState.deviceOrientation,
|
||||
);
|
||||
if (mapped.length < 4) return null;
|
||||
return _axisSquareAroundCorners(
|
||||
mapped,
|
||||
widget.layoutSize,
|
||||
padding: _qrBoundsPadding,
|
||||
);
|
||||
}
|
||||
|
||||
static Rect _axisSquareAroundCorners(
|
||||
List<Offset> corners,
|
||||
Size layout, {
|
||||
required double padding,
|
||||
}) {
|
||||
var minX = double.infinity;
|
||||
var maxX = double.negativeInfinity;
|
||||
var minY = double.infinity;
|
||||
var maxY = double.negativeInfinity;
|
||||
for (final o in corners) {
|
||||
minX = math.min(minX, o.dx);
|
||||
maxX = math.max(maxX, o.dx);
|
||||
minY = math.min(minY, o.dy);
|
||||
maxY = math.max(maxY, o.dy);
|
||||
}
|
||||
final bw = maxX - minX + 2 * padding;
|
||||
final bh = maxY - minY + 2 * padding;
|
||||
final side = math.max(bw, bh);
|
||||
final cx = (minX + maxX) / 2;
|
||||
final cy = (minY + maxY) / 2;
|
||||
var r = Rect.fromCenter(center: Offset(cx, cy), width: side, height: side);
|
||||
r = r.intersect(Rect.fromLTWH(0, 0, layout.width, layout.height));
|
||||
if (r.isEmpty) {
|
||||
return Rect.fromLTWH(0, 0, layout.shortestSide * 0.5, layout.shortestSide * 0.5);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static RRect _finderRRect(Rect rect, double maxRadius) {
|
||||
final r = math.min(
|
||||
maxRadius,
|
||||
math.min(rect.width, rect.height) * 0.14,
|
||||
);
|
||||
return RRect.fromRectAndRadius(rect, Radius.circular(r));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, state, _) {
|
||||
if (!state.isInitialized || !state.isRunning || state.error != null) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
|
||||
final finderRect = _interpolatedFinderRect();
|
||||
final rrect = _finderRRect(finderRect, _frameCornerRadius);
|
||||
final frameColor =
|
||||
_qrInView ? const Color(0xFF4ADE80) : Colors.white;
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: widget.layoutSize,
|
||||
painter: _ScannerDimOutsideRRectPainter(
|
||||
hole: rrect,
|
||||
dimColor: Colors.black.withValues(alpha: 0.58),
|
||||
),
|
||||
),
|
||||
CustomPaint(
|
||||
size: widget.layoutSize,
|
||||
painter: _FinderRoundedSquareStrokePainter(
|
||||
rrect: rrect,
|
||||
color: frameColor,
|
||||
strokeWidth: 3.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScannerDimOutsideRRectPainter extends CustomPainter {
|
||||
_ScannerDimOutsideRRectPainter({
|
||||
required this.hole,
|
||||
required this.dimColor,
|
||||
});
|
||||
|
||||
final RRect hole;
|
||||
final Color dimColor;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final outer = Path()
|
||||
..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||
final inner = Path()..addRRect(hole);
|
||||
final mask = Path.combine(PathOperation.difference, outer, inner);
|
||||
canvas.drawPath(mask, Paint()..color = dimColor);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ScannerDimOutsideRRectPainter oldDelegate) {
|
||||
if (oldDelegate.dimColor != dimColor) return true;
|
||||
return oldDelegate.hole != hole;
|
||||
}
|
||||
}
|
||||
|
||||
class _FinderRoundedSquareStrokePainter extends CustomPainter {
|
||||
_FinderRoundedSquareStrokePainter({
|
||||
required this.rrect,
|
||||
required this.color,
|
||||
required this.strokeWidth,
|
||||
});
|
||||
|
||||
final RRect rrect;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final shadowPaint = Paint()
|
||||
..color = Colors.black.withValues(alpha: 0.45)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth + 2.5
|
||||
..strokeCap = StrokeCap.round
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawRRect(rrect, shadowPaint);
|
||||
canvas.drawRRect(rrect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _FinderRoundedSquareStrokePainter oldDelegate) {
|
||||
return oldDelegate.rrect != rrect ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.strokeWidth != strokeWidth;
|
||||
}
|
||||
}
|
||||
|
||||
List<Offset> _mapBarcodeCornersToLayout(
|
||||
List<Offset> barcodeCorners,
|
||||
Size cameraPreviewSize,
|
||||
Size layoutSize,
|
||||
DeviceOrientation deviceOrientation,
|
||||
) {
|
||||
if (barcodeCorners.length < 4 || cameraPreviewSize.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final isLandscape = deviceOrientation == DeviceOrientation.landscapeLeft ||
|
||||
deviceOrientation == DeviceOrientation.landscapeRight;
|
||||
final cam = isLandscape ? cameraPreviewSize.flipped : cameraPreviewSize;
|
||||
|
||||
final wr = layoutSize.width / cam.width;
|
||||
final hr = layoutSize.height / cam.height;
|
||||
final ratio = math.max(wr, hr);
|
||||
final hPad = (cam.width * ratio - layoutSize.width) / 2;
|
||||
final vPad = (cam.height * ratio - layoutSize.height) / 2;
|
||||
|
||||
return [
|
||||
for (final o in barcodeCorners)
|
||||
Offset(
|
||||
o.dx * ratio - hPad,
|
||||
o.dy * ratio - vPad,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
void showCustomNotification(BuildContext context, String message) {
|
||||
final overlay = Overlay.of(context);
|
||||
showCustomNotificationOnOverlay(Overlay.of(context), message);
|
||||
}
|
||||
|
||||
void showCustomNotificationOnOverlay(OverlayState overlay, String message) {
|
||||
final entry = OverlayEntry(
|
||||
builder: (context) => CustomNotification(message: message),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"loginTitle": "Sign in to Komet",
|
||||
"loginSubtitle": "Check your country code and enter your\nphone number.",
|
||||
"loginCountry": "Country",
|
||||
"loginPhoneNumber": "Phone number",
|
||||
"loginOtherSignInMethods": "Other sign-in methods",
|
||||
"loginTermsIntro": "By continuing, you agree to \n",
|
||||
"loginTermsLink": "the terms of use",
|
||||
"loginTermsOfUse": "Terms of use",
|
||||
"loginConfirmPhoneTitle": "Is this the correct number?",
|
||||
"loginEdit": "Change",
|
||||
"loginDone": "Done",
|
||||
"loginReadTermsNotification": "Please read the terms of use first",
|
||||
"loginSpoofRedacted": "Spoofing",
|
||||
"loginProxy": "Proxy",
|
||||
"loginChangeServer": "Change server",
|
||||
"serverSettingsTitle": "Server",
|
||||
"serverHostLabel": "Host",
|
||||
"serverPortLabel": "Port",
|
||||
"serverApply": "Apply and reconnect",
|
||||
"serverUseDefault": "Reset to default",
|
||||
"serverInvalidHostOrPort": "Enter a valid host and port (1–65535)",
|
||||
"serverSettingsSaved": "Server settings applied",
|
||||
"serverReconnectFailed": "Could not connect to the server",
|
||||
"loginSignInWithQr": "Sign in with QR code",
|
||||
"loginSignInWithToken": "Sign in with token",
|
||||
"loginSignInWithSessionFile": "Sign in with session file",
|
||||
"loginLanguage": "Language",
|
||||
"languageNameRu": "Русский",
|
||||
"languageNameEn": "English",
|
||||
"selectCountryTitle": "Select country",
|
||||
"selectCountrySearchHint": "Search countries…",
|
||||
"codeConfirmationSmsSent": "We sent an SMS with a verification code to your phone number.",
|
||||
"codeResendInSeconds": "Resend in {seconds} s.",
|
||||
"@codeResendInSeconds": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codeResendSms": "Resend code via SMS",
|
||||
"codeError2faMissing": "Error: missing data for 2FA",
|
||||
|
||||
"proxySettingsTitle": "Proxy",
|
||||
"proxyTypeNone": "Disabled",
|
||||
"proxyTypeSocks5": "SOCKS5",
|
||||
"proxyTypeHttp": "HTTP(S)",
|
||||
"proxyHostLabel": "Proxy host",
|
||||
"proxyPortLabel": "Proxy port",
|
||||
"proxyUsernameLabel": "Username (optional)",
|
||||
"proxyPasswordLabel": "Password (optional)",
|
||||
"proxyApply": "Apply and reconnect",
|
||||
"proxyDisable": "Disable proxy",
|
||||
"proxySettingsSaved": "Proxy settings applied",
|
||||
"proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)",
|
||||
|
||||
"spoofScreenTitle": "Session spoofing",
|
||||
"spoofInfoHint": "Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.",
|
||||
"spoofMethodTitle": "Spoofing method",
|
||||
"spoofMethodPartial": "Partial",
|
||||
"spoofMethodFull": "Full",
|
||||
"spoofMethodPartialDescription": "Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.",
|
||||
"spoofMethodFullDescription": "All data including timezone and locale is generated randomly. Use this method at your own risk!",
|
||||
"spoofDeviceTypeTitle": "Device type",
|
||||
"spoofDeviceTypeDescription": "This field is not changeable, to avoid token association issues",
|
||||
"spoofDeviceTypeLabel": "Device type",
|
||||
"spoofMainSectionTitle": "Main data",
|
||||
"spoofFieldDeviceName": "Device name",
|
||||
"spoofFieldOsVersion": "OS version",
|
||||
"spoofRegionalSectionTitle": "Regional data",
|
||||
"spoofFieldScreen": "Screen resolution",
|
||||
"spoofFieldTimezone": "Timezone",
|
||||
"spoofFieldLocale": "Locale",
|
||||
"spoofIdentifiersSectionTitle": "Identifiers",
|
||||
"spoofIdentifiersDescription": "mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.",
|
||||
"spoofFieldDeviceId": "Device ID",
|
||||
"spoofRegenerateIdTooltip": "Generate a new ID",
|
||||
"spoofFieldAppVersion": "App version",
|
||||
"spoofFieldBuildNumber": "Build number",
|
||||
"spoofFieldArchitecture": "Architecture",
|
||||
"spoofButtonGenerate": "Generate",
|
||||
"spoofButtonApply": "Apply",
|
||||
"spoofDialogUnsureTitle": "Are you sure?",
|
||||
"spoofDialogUnsureContent": "The app may become unstable due to API incompatibility",
|
||||
"spoofDialogCancel": "Cancel",
|
||||
"spoofDialogYes": "Yes",
|
||||
"spoofDialogApplyTitle": "Apply settings?",
|
||||
"spoofDialogApplyContent": "Need to reconnect the app, ok?",
|
||||
"spoofDialogApplyWarning": "Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible",
|
||||
"spoofDialogReloginTitle": "Done!",
|
||||
"spoofDialogReloginContent": "Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.",
|
||||
"spoofDialogReloginWarning": "Re-login now?",
|
||||
"spoofDialogReloginDeny": "Later",
|
||||
"spoofDialogReloginConfirm": "Re-login now",
|
||||
"spoofDialogApplyDeny": "No",
|
||||
"spoofDialogApplyConfirm": "Ok!",
|
||||
"spoofErrorApplyFailed": "Failed to apply settings: {error}",
|
||||
"@spoofErrorApplyFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenuSpoof": "Spoofing"
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_ru.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// Callers can lookup localized strings with an instance of AppLocalizations
|
||||
/// returned by `AppLocalizations.of(context)`.
|
||||
///
|
||||
/// Applications need to include `AppLocalizations.delegate()` in their app's
|
||||
/// `localizationDelegates` list, and the locales they support in the app's
|
||||
/// `supportedLocales` list. For example:
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'l10n/app_localizations.dart';
|
||||
///
|
||||
/// return MaterialApp(
|
||||
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
/// supportedLocales: AppLocalizations.supportedLocales,
|
||||
/// home: MyApplicationHome(),
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// ## Update pubspec.yaml
|
||||
///
|
||||
/// Please make sure to update your pubspec.yaml to include the following
|
||||
/// packages:
|
||||
///
|
||||
/// ```yaml
|
||||
/// dependencies:
|
||||
/// # Internationalization support.
|
||||
/// flutter_localizations:
|
||||
/// sdk: flutter
|
||||
/// intl: any # Use the pinned version from flutter_localizations
|
||||
///
|
||||
/// # Rest of dependencies
|
||||
/// ```
|
||||
///
|
||||
/// ## iOS Applications
|
||||
///
|
||||
/// iOS applications define key application metadata, including supported
|
||||
/// locales, in an Info.plist file that is built into the application bundle.
|
||||
/// To configure the locales supported by your app, you’ll need to edit this
|
||||
/// file.
|
||||
///
|
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||
/// project’s Runner folder.
|
||||
///
|
||||
/// Next, select the Information Property List item, select Add Item from the
|
||||
/// Editor menu, then select Localizations from the pop-up menu.
|
||||
///
|
||||
/// Select and expand the newly-created Localizations item then, for each
|
||||
/// locale your application supports, add a new item and select the locale
|
||||
/// you wish to add from the pop-up menu in the Value field. This list should
|
||||
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
|
||||
/// property.
|
||||
abstract class AppLocalizations {
|
||||
AppLocalizations(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
static AppLocalizations? of(BuildContext context) {
|
||||
return Localizations.of<AppLocalizations>(context, AppLocalizations);
|
||||
}
|
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> delegate =
|
||||
_AppLocalizationsDelegate();
|
||||
|
||||
/// A list of this localizations delegate along with the default localizations
|
||||
/// delegates.
|
||||
///
|
||||
/// Returns a list of localizations delegates containing this delegate along with
|
||||
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
|
||||
/// and GlobalWidgetsLocalizations.delegate.
|
||||
///
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
];
|
||||
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('ru'),
|
||||
];
|
||||
|
||||
/// No description provided for @loginTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in to Komet'**
|
||||
String get loginTitle;
|
||||
|
||||
/// No description provided for @loginSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check your country code and enter your\nphone number.'**
|
||||
String get loginSubtitle;
|
||||
|
||||
/// No description provided for @loginCountry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Country'**
|
||||
String get loginCountry;
|
||||
|
||||
/// No description provided for @loginPhoneNumber.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Phone number'**
|
||||
String get loginPhoneNumber;
|
||||
|
||||
/// No description provided for @loginOtherSignInMethods.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Other sign-in methods'**
|
||||
String get loginOtherSignInMethods;
|
||||
|
||||
/// No description provided for @loginTermsIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By continuing, you agree to \n'**
|
||||
String get loginTermsIntro;
|
||||
|
||||
/// No description provided for @loginTermsLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'the terms of use'**
|
||||
String get loginTermsLink;
|
||||
|
||||
/// No description provided for @loginTermsOfUse.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Terms of use'**
|
||||
String get loginTermsOfUse;
|
||||
|
||||
/// No description provided for @loginConfirmPhoneTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Is this the correct number?'**
|
||||
String get loginConfirmPhoneTitle;
|
||||
|
||||
/// No description provided for @loginEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change'**
|
||||
String get loginEdit;
|
||||
|
||||
/// No description provided for @loginDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get loginDone;
|
||||
|
||||
/// No description provided for @loginReadTermsNotification.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please read the terms of use first'**
|
||||
String get loginReadTermsNotification;
|
||||
|
||||
/// No description provided for @loginSpoofRedacted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spoofing'**
|
||||
String get loginSpoofRedacted;
|
||||
|
||||
/// No description provided for @loginProxy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy'**
|
||||
String get loginProxy;
|
||||
|
||||
/// No description provided for @loginChangeServer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change server'**
|
||||
String get loginChangeServer;
|
||||
|
||||
/// No description provided for @serverSettingsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server'**
|
||||
String get serverSettingsTitle;
|
||||
|
||||
/// No description provided for @serverHostLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Host'**
|
||||
String get serverHostLabel;
|
||||
|
||||
/// No description provided for @serverPortLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Port'**
|
||||
String get serverPortLabel;
|
||||
|
||||
/// No description provided for @serverApply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply and reconnect'**
|
||||
String get serverApply;
|
||||
|
||||
/// No description provided for @serverUseDefault.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset to default'**
|
||||
String get serverUseDefault;
|
||||
|
||||
/// No description provided for @serverInvalidHostOrPort.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a valid host and port (1–65535)'**
|
||||
String get serverInvalidHostOrPort;
|
||||
|
||||
/// No description provided for @serverSettingsSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server settings applied'**
|
||||
String get serverSettingsSaved;
|
||||
|
||||
/// No description provided for @serverReconnectFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not connect to the server'**
|
||||
String get serverReconnectFailed;
|
||||
|
||||
/// No description provided for @loginSignInWithQr.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with QR code'**
|
||||
String get loginSignInWithQr;
|
||||
|
||||
/// No description provided for @loginSignInWithToken.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with token'**
|
||||
String get loginSignInWithToken;
|
||||
|
||||
/// No description provided for @loginSignInWithSessionFile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with session file'**
|
||||
String get loginSignInWithSessionFile;
|
||||
|
||||
/// No description provided for @loginLanguage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Language'**
|
||||
String get loginLanguage;
|
||||
|
||||
/// No description provided for @languageNameRu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Русский'**
|
||||
String get languageNameRu;
|
||||
|
||||
/// No description provided for @languageNameEn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'English'**
|
||||
String get languageNameEn;
|
||||
|
||||
/// No description provided for @selectCountryTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select country'**
|
||||
String get selectCountryTitle;
|
||||
|
||||
/// No description provided for @selectCountrySearchHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search countries…'**
|
||||
String get selectCountrySearchHint;
|
||||
|
||||
/// No description provided for @codeConfirmationSmsSent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'We sent an SMS with a verification code to your phone number.'**
|
||||
String get codeConfirmationSmsSent;
|
||||
|
||||
/// No description provided for @codeResendInSeconds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Resend in {seconds} s.'**
|
||||
String codeResendInSeconds(int seconds);
|
||||
|
||||
/// No description provided for @codeResendSms.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Resend code via SMS'**
|
||||
String get codeResendSms;
|
||||
|
||||
/// No description provided for @codeError2faMissing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Error: missing data for 2FA'**
|
||||
String get codeError2faMissing;
|
||||
|
||||
/// No description provided for @proxySettingsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy'**
|
||||
String get proxySettingsTitle;
|
||||
|
||||
/// No description provided for @proxyTypeNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled'**
|
||||
String get proxyTypeNone;
|
||||
|
||||
/// No description provided for @proxyTypeSocks5.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SOCKS5'**
|
||||
String get proxyTypeSocks5;
|
||||
|
||||
/// No description provided for @proxyTypeHttp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'HTTP(S)'**
|
||||
String get proxyTypeHttp;
|
||||
|
||||
/// No description provided for @proxyHostLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy host'**
|
||||
String get proxyHostLabel;
|
||||
|
||||
/// No description provided for @proxyPortLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy port'**
|
||||
String get proxyPortLabel;
|
||||
|
||||
/// No description provided for @proxyUsernameLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Username (optional)'**
|
||||
String get proxyUsernameLabel;
|
||||
|
||||
/// No description provided for @proxyPasswordLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password (optional)'**
|
||||
String get proxyPasswordLabel;
|
||||
|
||||
/// No description provided for @proxyApply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply and reconnect'**
|
||||
String get proxyApply;
|
||||
|
||||
/// No description provided for @proxyDisable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disable proxy'**
|
||||
String get proxyDisable;
|
||||
|
||||
/// No description provided for @proxySettingsSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proxy settings applied'**
|
||||
String get proxySettingsSaved;
|
||||
|
||||
/// No description provided for @proxyInvalidHostOrPort.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a valid proxy host and port (1–65535)'**
|
||||
String get proxyInvalidHostOrPort;
|
||||
|
||||
/// No description provided for @spoofScreenTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Session spoofing'**
|
||||
String get spoofScreenTitle;
|
||||
|
||||
/// No description provided for @spoofInfoHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.'**
|
||||
String get spoofInfoHint;
|
||||
|
||||
/// No description provided for @spoofMethodTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spoofing method'**
|
||||
String get spoofMethodTitle;
|
||||
|
||||
/// No description provided for @spoofMethodPartial.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Partial'**
|
||||
String get spoofMethodPartial;
|
||||
|
||||
/// No description provided for @spoofMethodFull.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Full'**
|
||||
String get spoofMethodFull;
|
||||
|
||||
/// No description provided for @spoofMethodPartialDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.'**
|
||||
String get spoofMethodPartialDescription;
|
||||
|
||||
/// No description provided for @spoofMethodFullDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All data including timezone and locale is generated randomly. Use this method at your own risk!'**
|
||||
String get spoofMethodFullDescription;
|
||||
|
||||
/// No description provided for @spoofDeviceTypeTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device type'**
|
||||
String get spoofDeviceTypeTitle;
|
||||
|
||||
/// No description provided for @spoofDeviceTypeDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This field is not changeable, to avoid token association issues'**
|
||||
String get spoofDeviceTypeDescription;
|
||||
|
||||
/// No description provided for @spoofDeviceTypeLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device type'**
|
||||
String get spoofDeviceTypeLabel;
|
||||
|
||||
/// No description provided for @spoofMainSectionTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Main data'**
|
||||
String get spoofMainSectionTitle;
|
||||
|
||||
/// No description provided for @spoofFieldDeviceName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device name'**
|
||||
String get spoofFieldDeviceName;
|
||||
|
||||
/// No description provided for @spoofFieldOsVersion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'OS version'**
|
||||
String get spoofFieldOsVersion;
|
||||
|
||||
/// No description provided for @spoofRegionalSectionTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Regional data'**
|
||||
String get spoofRegionalSectionTitle;
|
||||
|
||||
/// No description provided for @spoofFieldScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Screen resolution'**
|
||||
String get spoofFieldScreen;
|
||||
|
||||
/// No description provided for @spoofFieldTimezone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Timezone'**
|
||||
String get spoofFieldTimezone;
|
||||
|
||||
/// No description provided for @spoofFieldLocale.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Locale'**
|
||||
String get spoofFieldLocale;
|
||||
|
||||
/// No description provided for @spoofIdentifiersSectionTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Identifiers'**
|
||||
String get spoofIdentifiersSectionTitle;
|
||||
|
||||
/// No description provided for @spoofIdentifiersDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'**
|
||||
String get spoofIdentifiersDescription;
|
||||
|
||||
/// No description provided for @spoofFieldDeviceId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device ID'**
|
||||
String get spoofFieldDeviceId;
|
||||
|
||||
/// No description provided for @spoofRegenerateIdTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Generate a new ID'**
|
||||
String get spoofRegenerateIdTooltip;
|
||||
|
||||
/// No description provided for @spoofFieldAppVersion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'App version'**
|
||||
String get spoofFieldAppVersion;
|
||||
|
||||
/// No description provided for @spoofFieldBuildNumber.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Build number'**
|
||||
String get spoofFieldBuildNumber;
|
||||
|
||||
/// No description provided for @spoofFieldArchitecture.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Architecture'**
|
||||
String get spoofFieldArchitecture;
|
||||
|
||||
/// No description provided for @spoofButtonGenerate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Generate'**
|
||||
String get spoofButtonGenerate;
|
||||
|
||||
/// No description provided for @spoofButtonApply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply'**
|
||||
String get spoofButtonApply;
|
||||
|
||||
/// No description provided for @spoofDialogUnsureTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Are you sure?'**
|
||||
String get spoofDialogUnsureTitle;
|
||||
|
||||
/// No description provided for @spoofDialogUnsureContent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The app may become unstable due to API incompatibility'**
|
||||
String get spoofDialogUnsureContent;
|
||||
|
||||
/// No description provided for @spoofDialogCancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel'**
|
||||
String get spoofDialogCancel;
|
||||
|
||||
/// No description provided for @spoofDialogYes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Yes'**
|
||||
String get spoofDialogYes;
|
||||
|
||||
/// No description provided for @spoofDialogApplyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply settings?'**
|
||||
String get spoofDialogApplyTitle;
|
||||
|
||||
/// No description provided for @spoofDialogApplyContent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Need to reconnect the app, ok?'**
|
||||
String get spoofDialogApplyContent;
|
||||
|
||||
/// No description provided for @spoofDialogApplyWarning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible'**
|
||||
String get spoofDialogApplyWarning;
|
||||
|
||||
/// No description provided for @spoofDialogReloginTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done!'**
|
||||
String get spoofDialogReloginTitle;
|
||||
|
||||
/// No description provided for @spoofDialogReloginContent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.'**
|
||||
String get spoofDialogReloginContent;
|
||||
|
||||
/// No description provided for @spoofDialogReloginWarning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-login now?'**
|
||||
String get spoofDialogReloginWarning;
|
||||
|
||||
/// No description provided for @spoofDialogReloginDeny.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Later'**
|
||||
String get spoofDialogReloginDeny;
|
||||
|
||||
/// No description provided for @spoofDialogReloginConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-login now'**
|
||||
String get spoofDialogReloginConfirm;
|
||||
|
||||
/// No description provided for @spoofDialogApplyDeny.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No'**
|
||||
String get spoofDialogApplyDeny;
|
||||
|
||||
/// No description provided for @spoofDialogApplyConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ok!'**
|
||||
String get spoofDialogApplyConfirm;
|
||||
|
||||
/// No description provided for @spoofErrorApplyFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to apply settings: {error}'**
|
||||
String spoofErrorApplyFailed(String error);
|
||||
|
||||
/// No description provided for @profileMenuSpoof.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spoofing'**
|
||||
String get profileMenuSpoof;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
extends LocalizationsDelegate<AppLocalizations> {
|
||||
const _AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) {
|
||||
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['en', 'ru'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
}
|
||||
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
return AppLocalizationsEn();
|
||||
case 'ru':
|
||||
return AppLocalizationsRu();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for English (`en`).
|
||||
class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get loginTitle => 'Sign in to Komet';
|
||||
|
||||
@override
|
||||
String get loginSubtitle =>
|
||||
'Check your country code and enter your\nphone number.';
|
||||
|
||||
@override
|
||||
String get loginCountry => 'Country';
|
||||
|
||||
@override
|
||||
String get loginPhoneNumber => 'Phone number';
|
||||
|
||||
@override
|
||||
String get loginOtherSignInMethods => 'Other sign-in methods';
|
||||
|
||||
@override
|
||||
String get loginTermsIntro => 'By continuing, you agree to \n';
|
||||
|
||||
@override
|
||||
String get loginTermsLink => 'the terms of use';
|
||||
|
||||
@override
|
||||
String get loginTermsOfUse => 'Terms of use';
|
||||
|
||||
@override
|
||||
String get loginConfirmPhoneTitle => 'Is this the correct number?';
|
||||
|
||||
@override
|
||||
String get loginEdit => 'Change';
|
||||
|
||||
@override
|
||||
String get loginDone => 'Done';
|
||||
|
||||
@override
|
||||
String get loginReadTermsNotification => 'Please read the terms of use first';
|
||||
|
||||
@override
|
||||
String get loginSpoofRedacted => 'Spoofing';
|
||||
|
||||
@override
|
||||
String get loginProxy => 'Proxy';
|
||||
|
||||
@override
|
||||
String get loginChangeServer => 'Change server';
|
||||
|
||||
@override
|
||||
String get serverSettingsTitle => 'Server';
|
||||
|
||||
@override
|
||||
String get serverHostLabel => 'Host';
|
||||
|
||||
@override
|
||||
String get serverPortLabel => 'Port';
|
||||
|
||||
@override
|
||||
String get serverApply => 'Apply and reconnect';
|
||||
|
||||
@override
|
||||
String get serverUseDefault => 'Reset to default';
|
||||
|
||||
@override
|
||||
String get serverInvalidHostOrPort => 'Enter a valid host and port (1–65535)';
|
||||
|
||||
@override
|
||||
String get serverSettingsSaved => 'Server settings applied';
|
||||
|
||||
@override
|
||||
String get serverReconnectFailed => 'Could not connect to the server';
|
||||
|
||||
@override
|
||||
String get loginSignInWithQr => 'Sign in with QR code';
|
||||
|
||||
@override
|
||||
String get loginSignInWithToken => 'Sign in with token';
|
||||
|
||||
@override
|
||||
String get loginSignInWithSessionFile => 'Sign in with session file';
|
||||
|
||||
@override
|
||||
String get loginLanguage => 'Language';
|
||||
|
||||
@override
|
||||
String get languageNameRu => 'Русский';
|
||||
|
||||
@override
|
||||
String get languageNameEn => 'English';
|
||||
|
||||
@override
|
||||
String get selectCountryTitle => 'Select country';
|
||||
|
||||
@override
|
||||
String get selectCountrySearchHint => 'Search countries…';
|
||||
|
||||
@override
|
||||
String get codeConfirmationSmsSent =>
|
||||
'We sent an SMS with a verification code to your phone number.';
|
||||
|
||||
@override
|
||||
String codeResendInSeconds(int seconds) {
|
||||
return 'Resend in $seconds s.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get codeResendSms => 'Resend code via SMS';
|
||||
|
||||
@override
|
||||
String get codeError2faMissing => 'Error: missing data for 2FA';
|
||||
|
||||
@override
|
||||
String get proxySettingsTitle => 'Proxy';
|
||||
|
||||
@override
|
||||
String get proxyTypeNone => 'Disabled';
|
||||
|
||||
@override
|
||||
String get proxyTypeSocks5 => 'SOCKS5';
|
||||
|
||||
@override
|
||||
String get proxyTypeHttp => 'HTTP(S)';
|
||||
|
||||
@override
|
||||
String get proxyHostLabel => 'Proxy host';
|
||||
|
||||
@override
|
||||
String get proxyPortLabel => 'Proxy port';
|
||||
|
||||
@override
|
||||
String get proxyUsernameLabel => 'Username (optional)';
|
||||
|
||||
@override
|
||||
String get proxyPasswordLabel => 'Password (optional)';
|
||||
|
||||
@override
|
||||
String get proxyApply => 'Apply and reconnect';
|
||||
|
||||
@override
|
||||
String get proxyDisable => 'Disable proxy';
|
||||
|
||||
@override
|
||||
String get proxySettingsSaved => 'Proxy settings applied';
|
||||
|
||||
@override
|
||||
String get proxyInvalidHostOrPort =>
|
||||
'Enter a valid proxy host and port (1–65535)';
|
||||
|
||||
@override
|
||||
String get spoofScreenTitle => 'Session spoofing';
|
||||
|
||||
@override
|
||||
String get spoofInfoHint =>
|
||||
'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.';
|
||||
|
||||
@override
|
||||
String get spoofMethodTitle => 'Spoofing method';
|
||||
|
||||
@override
|
||||
String get spoofMethodPartial => 'Partial';
|
||||
|
||||
@override
|
||||
String get spoofMethodFull => 'Full';
|
||||
|
||||
@override
|
||||
String get spoofMethodPartialDescription =>
|
||||
'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.';
|
||||
|
||||
@override
|
||||
String get spoofMethodFullDescription =>
|
||||
'All data including timezone and locale is generated randomly. Use this method at your own risk!';
|
||||
|
||||
@override
|
||||
String get spoofDeviceTypeTitle => 'Device type';
|
||||
|
||||
@override
|
||||
String get spoofDeviceTypeDescription =>
|
||||
'This field is not changeable, to avoid token association issues';
|
||||
|
||||
@override
|
||||
String get spoofDeviceTypeLabel => 'Device type';
|
||||
|
||||
@override
|
||||
String get spoofMainSectionTitle => 'Main data';
|
||||
|
||||
@override
|
||||
String get spoofFieldDeviceName => 'Device name';
|
||||
|
||||
@override
|
||||
String get spoofFieldOsVersion => 'OS version';
|
||||
|
||||
@override
|
||||
String get spoofRegionalSectionTitle => 'Regional data';
|
||||
|
||||
@override
|
||||
String get spoofFieldScreen => 'Screen resolution';
|
||||
|
||||
@override
|
||||
String get spoofFieldTimezone => 'Timezone';
|
||||
|
||||
@override
|
||||
String get spoofFieldLocale => 'Locale';
|
||||
|
||||
@override
|
||||
String get spoofIdentifiersSectionTitle => 'Identifiers';
|
||||
|
||||
@override
|
||||
String get spoofIdentifiersDescription =>
|
||||
'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.';
|
||||
|
||||
@override
|
||||
String get spoofFieldDeviceId => 'Device ID';
|
||||
|
||||
@override
|
||||
String get spoofRegenerateIdTooltip => 'Generate a new ID';
|
||||
|
||||
@override
|
||||
String get spoofFieldAppVersion => 'App version';
|
||||
|
||||
@override
|
||||
String get spoofFieldBuildNumber => 'Build number';
|
||||
|
||||
@override
|
||||
String get spoofFieldArchitecture => 'Architecture';
|
||||
|
||||
@override
|
||||
String get spoofButtonGenerate => 'Generate';
|
||||
|
||||
@override
|
||||
String get spoofButtonApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get spoofDialogUnsureTitle => 'Are you sure?';
|
||||
|
||||
@override
|
||||
String get spoofDialogUnsureContent =>
|
||||
'The app may become unstable due to API incompatibility';
|
||||
|
||||
@override
|
||||
String get spoofDialogCancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get spoofDialogYes => 'Yes';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyTitle => 'Apply settings?';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyContent => 'Need to reconnect the app, ok?';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyWarning =>
|
||||
'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginTitle => 'Done!';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginContent =>
|
||||
'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginWarning => 'Re-login now?';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginDeny => 'Later';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginConfirm => 'Re-login now';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyDeny => 'No';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyConfirm => 'Ok!';
|
||||
|
||||
@override
|
||||
String spoofErrorApplyFailed(String error) {
|
||||
return 'Failed to apply settings: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get profileMenuSpoof => 'Spoofing';
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Russian (`ru`).
|
||||
class AppLocalizationsRu extends AppLocalizations {
|
||||
AppLocalizationsRu([String locale = 'ru']) : super(locale);
|
||||
|
||||
@override
|
||||
String get loginTitle => 'Войдите в Komet';
|
||||
|
||||
@override
|
||||
String get loginSubtitle =>
|
||||
'Проверьте код страны и введите свой\nномер телефона.';
|
||||
|
||||
@override
|
||||
String get loginCountry => 'Страна';
|
||||
|
||||
@override
|
||||
String get loginPhoneNumber => 'Номер телефона';
|
||||
|
||||
@override
|
||||
String get loginOtherSignInMethods => 'Другие способы входа';
|
||||
|
||||
@override
|
||||
String get loginTermsIntro => 'Продолжая, вы соглашаетесь с \n';
|
||||
|
||||
@override
|
||||
String get loginTermsLink => 'пользовательскими соглашениями';
|
||||
|
||||
@override
|
||||
String get loginTermsOfUse => 'Условия использования';
|
||||
|
||||
@override
|
||||
String get loginConfirmPhoneTitle => 'Это правильный номер?';
|
||||
|
||||
@override
|
||||
String get loginEdit => 'Изменить';
|
||||
|
||||
@override
|
||||
String get loginDone => 'Готово';
|
||||
|
||||
@override
|
||||
String get loginReadTermsNotification =>
|
||||
'Сначала прочитайте условия использования';
|
||||
|
||||
@override
|
||||
String get loginSpoofRedacted => 'Подмена данных';
|
||||
|
||||
@override
|
||||
String get loginProxy => 'Прокси';
|
||||
|
||||
@override
|
||||
String get loginChangeServer => 'Смена сервера';
|
||||
|
||||
@override
|
||||
String get serverSettingsTitle => 'Сервер';
|
||||
|
||||
@override
|
||||
String get serverHostLabel => 'Хост';
|
||||
|
||||
@override
|
||||
String get serverPortLabel => 'Порт';
|
||||
|
||||
@override
|
||||
String get serverApply => 'Применить и переподключиться';
|
||||
|
||||
@override
|
||||
String get serverUseDefault => 'Сбросить к умолчанию';
|
||||
|
||||
@override
|
||||
String get serverInvalidHostOrPort =>
|
||||
'Укажите корректный хост и порт (1–65535)';
|
||||
|
||||
@override
|
||||
String get serverSettingsSaved => 'Настройки сервера применены';
|
||||
|
||||
@override
|
||||
String get serverReconnectFailed => 'Не удалось подключиться к серверу';
|
||||
|
||||
@override
|
||||
String get loginSignInWithQr => 'По QR code';
|
||||
|
||||
@override
|
||||
String get loginSignInWithToken => 'По токену';
|
||||
|
||||
@override
|
||||
String get loginSignInWithSessionFile => 'По файлу сессии';
|
||||
|
||||
@override
|
||||
String get loginLanguage => 'Язык';
|
||||
|
||||
@override
|
||||
String get languageNameRu => 'Русский';
|
||||
|
||||
@override
|
||||
String get languageNameEn => 'English';
|
||||
|
||||
@override
|
||||
String get selectCountryTitle => 'Выберите страну';
|
||||
|
||||
@override
|
||||
String get selectCountrySearchHint => 'Поиск страны…';
|
||||
|
||||
@override
|
||||
String get codeConfirmationSmsSent =>
|
||||
'Мы отправили SMS с кодом подтверждения на ваш номер телефона.';
|
||||
|
||||
@override
|
||||
String codeResendInSeconds(int seconds) {
|
||||
return 'Отправить повторно через $seconds сек.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get codeResendSms => 'Отправить код по SMS';
|
||||
|
||||
@override
|
||||
String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA';
|
||||
|
||||
@override
|
||||
String get proxySettingsTitle => 'Прокси';
|
||||
|
||||
@override
|
||||
String get proxyTypeNone => 'Выключен';
|
||||
|
||||
@override
|
||||
String get proxyTypeSocks5 => 'SOCKS5';
|
||||
|
||||
@override
|
||||
String get proxyTypeHttp => 'HTTP(S)';
|
||||
|
||||
@override
|
||||
String get proxyHostLabel => 'Хост прокси';
|
||||
|
||||
@override
|
||||
String get proxyPortLabel => 'Порт прокси';
|
||||
|
||||
@override
|
||||
String get proxyUsernameLabel => 'Логин (необязательно)';
|
||||
|
||||
@override
|
||||
String get proxyPasswordLabel => 'Пароль (необязательно)';
|
||||
|
||||
@override
|
||||
String get proxyApply => 'Применить и переподключиться';
|
||||
|
||||
@override
|
||||
String get proxyDisable => 'Отключить прокси';
|
||||
|
||||
@override
|
||||
String get proxySettingsSaved => 'Настройки прокси применены';
|
||||
|
||||
@override
|
||||
String get proxyInvalidHostOrPort =>
|
||||
'Укажите корректный хост и порт прокси (1–65535)';
|
||||
|
||||
@override
|
||||
String get spoofScreenTitle => 'Подмена данных сессии';
|
||||
|
||||
@override
|
||||
String get spoofInfoHint =>
|
||||
'Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.';
|
||||
|
||||
@override
|
||||
String get spoofMethodTitle => 'Метод подмены';
|
||||
|
||||
@override
|
||||
String get spoofMethodPartial => 'Частичный';
|
||||
|
||||
@override
|
||||
String get spoofMethodFull => 'Полный';
|
||||
|
||||
@override
|
||||
String get spoofMethodPartialDescription =>
|
||||
'Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.';
|
||||
|
||||
@override
|
||||
String get spoofMethodFullDescription =>
|
||||
'Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!';
|
||||
|
||||
@override
|
||||
String get spoofDeviceTypeTitle => 'Тип устройства';
|
||||
|
||||
@override
|
||||
String get spoofDeviceTypeDescription =>
|
||||
'Данное поле не изменяемое, во избежании проблем с ассоциацией токена';
|
||||
|
||||
@override
|
||||
String get spoofDeviceTypeLabel => 'Тип устройства';
|
||||
|
||||
@override
|
||||
String get spoofMainSectionTitle => 'Основные данные';
|
||||
|
||||
@override
|
||||
String get spoofFieldDeviceName => 'Имя устройства';
|
||||
|
||||
@override
|
||||
String get spoofFieldOsVersion => 'Версия ОС';
|
||||
|
||||
@override
|
||||
String get spoofRegionalSectionTitle => 'Региональные данные';
|
||||
|
||||
@override
|
||||
String get spoofFieldScreen => 'Разрешение экрана';
|
||||
|
||||
@override
|
||||
String get spoofFieldTimezone => 'Часовой пояс';
|
||||
|
||||
@override
|
||||
String get spoofFieldLocale => 'Локаль';
|
||||
|
||||
@override
|
||||
String get spoofIdentifiersSectionTitle => 'Идентификаторы';
|
||||
|
||||
@override
|
||||
String get spoofIdentifiersDescription =>
|
||||
'mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.';
|
||||
|
||||
@override
|
||||
String get spoofFieldDeviceId => 'ID Устройства';
|
||||
|
||||
@override
|
||||
String get spoofRegenerateIdTooltip => 'Сгенерировать новый ID';
|
||||
|
||||
@override
|
||||
String get spoofFieldAppVersion => 'Версия приложения';
|
||||
|
||||
@override
|
||||
String get spoofFieldBuildNumber => 'Build Number';
|
||||
|
||||
@override
|
||||
String get spoofFieldArchitecture => 'Архитектура';
|
||||
|
||||
@override
|
||||
String get spoofButtonGenerate => 'Сгенерировать';
|
||||
|
||||
@override
|
||||
String get spoofButtonApply => 'Применить';
|
||||
|
||||
@override
|
||||
String get spoofDialogUnsureTitle => 'Ты уверен?';
|
||||
|
||||
@override
|
||||
String get spoofDialogUnsureContent =>
|
||||
'Приложение может начать работать нестабильно из-за несовместимости API';
|
||||
|
||||
@override
|
||||
String get spoofDialogCancel => 'Отмена';
|
||||
|
||||
@override
|
||||
String get spoofDialogYes => 'Да';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyTitle => 'Применить настройки?';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyContent => 'Нужно перезайти в приложение, ок?';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyWarning =>
|
||||
'Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginTitle => 'Готово!';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginContent =>
|
||||
'Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginWarning => 'Перезайти сейчас?';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginDeny => 'Позже';
|
||||
|
||||
@override
|
||||
String get spoofDialogReloginConfirm => 'Перелогиниться сейчас';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyDeny => 'Не';
|
||||
|
||||
@override
|
||||
String get spoofDialogApplyConfirm => 'Ок!';
|
||||
|
||||
@override
|
||||
String spoofErrorApplyFailed(String error) {
|
||||
return 'Ошибка при применении настроек: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get profileMenuSpoof => 'Подмена данных';
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
"loginTitle": "Войдите в Komet",
|
||||
"loginSubtitle": "Проверьте код страны и введите свой\nномер телефона.",
|
||||
"loginCountry": "Страна",
|
||||
"loginPhoneNumber": "Номер телефона",
|
||||
"loginOtherSignInMethods": "Другие способы входа",
|
||||
"loginTermsIntro": "Продолжая, вы соглашаетесь с \n",
|
||||
"loginTermsLink": "пользовательскими соглашениями",
|
||||
"loginTermsOfUse": "Условия использования",
|
||||
"loginConfirmPhoneTitle": "Это правильный номер?",
|
||||
"loginEdit": "Изменить",
|
||||
"loginDone": "Готово",
|
||||
"loginReadTermsNotification": "Сначала прочитайте условия использования",
|
||||
"loginSpoofRedacted": "Подмена данных",
|
||||
"loginProxy": "Прокси",
|
||||
"loginChangeServer": "Смена сервера",
|
||||
"serverSettingsTitle": "Сервер",
|
||||
"serverHostLabel": "Хост",
|
||||
"serverPortLabel": "Порт",
|
||||
"serverApply": "Применить и переподключиться",
|
||||
"serverUseDefault": "Сбросить к умолчанию",
|
||||
"serverInvalidHostOrPort": "Укажите корректный хост и порт (1–65535)",
|
||||
"serverSettingsSaved": "Настройки сервера применены",
|
||||
"serverReconnectFailed": "Не удалось подключиться к серверу",
|
||||
"loginSignInWithQr": "По QR code",
|
||||
"loginSignInWithToken": "По токену",
|
||||
"loginSignInWithSessionFile": "По файлу сессии",
|
||||
"loginLanguage": "Язык",
|
||||
"languageNameRu": "Русский",
|
||||
"languageNameEn": "English",
|
||||
"selectCountryTitle": "Выберите страну",
|
||||
"selectCountrySearchHint": "Поиск страны…",
|
||||
"codeConfirmationSmsSent": "Мы отправили SMS с кодом подтверждения на ваш номер телефона.",
|
||||
"codeResendInSeconds": "Отправить повторно через {seconds} сек.",
|
||||
"@codeResendInSeconds": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codeResendSms": "Отправить код по SMS",
|
||||
"codeError2faMissing": "Ошибка: отсутствуют данные для 2FA",
|
||||
|
||||
"proxySettingsTitle": "Прокси",
|
||||
"proxyTypeNone": "Выключен",
|
||||
"proxyTypeSocks5": "SOCKS5",
|
||||
"proxyTypeHttp": "HTTP(S)",
|
||||
"proxyHostLabel": "Хост прокси",
|
||||
"proxyPortLabel": "Порт прокси",
|
||||
"proxyUsernameLabel": "Логин (необязательно)",
|
||||
"proxyPasswordLabel": "Пароль (необязательно)",
|
||||
"proxyApply": "Применить и переподключиться",
|
||||
"proxyDisable": "Отключить прокси",
|
||||
"proxySettingsSaved": "Настройки прокси применены",
|
||||
"proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)",
|
||||
|
||||
"spoofScreenTitle": "Подмена данных сессии",
|
||||
"spoofInfoHint": "Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.",
|
||||
"spoofMethodTitle": "Метод подмены",
|
||||
"spoofMethodPartial": "Частичный",
|
||||
"spoofMethodFull": "Полный",
|
||||
"spoofMethodPartialDescription": "Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.",
|
||||
"spoofMethodFullDescription": "Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!",
|
||||
"spoofDeviceTypeTitle": "Тип устройства",
|
||||
"spoofDeviceTypeDescription": "Данное поле не изменяемое, во избежании проблем с ассоциацией токена",
|
||||
"spoofDeviceTypeLabel": "Тип устройства",
|
||||
"spoofMainSectionTitle": "Основные данные",
|
||||
"spoofFieldDeviceName": "Имя устройства",
|
||||
"spoofFieldOsVersion": "Версия ОС",
|
||||
"spoofRegionalSectionTitle": "Региональные данные",
|
||||
"spoofFieldScreen": "Разрешение экрана",
|
||||
"spoofFieldTimezone": "Часовой пояс",
|
||||
"spoofFieldLocale": "Локаль",
|
||||
"spoofIdentifiersSectionTitle": "Идентификаторы",
|
||||
"spoofIdentifiersDescription": "mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.",
|
||||
"spoofFieldDeviceId": "ID Устройства",
|
||||
"spoofRegenerateIdTooltip": "Сгенерировать новый ID",
|
||||
"spoofFieldAppVersion": "Версия приложения",
|
||||
"spoofFieldBuildNumber": "Build Number",
|
||||
"spoofFieldArchitecture": "Архитектура",
|
||||
"spoofButtonGenerate": "Сгенерировать",
|
||||
"spoofButtonApply": "Применить",
|
||||
"spoofDialogUnsureTitle": "Ты уверен?",
|
||||
"spoofDialogUnsureContent": "Приложение может начать работать нестабильно из-за несовместимости API",
|
||||
"spoofDialogCancel": "Отмена",
|
||||
"spoofDialogYes": "Да",
|
||||
"spoofDialogApplyTitle": "Применить настройки?",
|
||||
"spoofDialogApplyContent": "Нужно перезайти в приложение, ок?",
|
||||
"spoofDialogApplyWarning": "Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт",
|
||||
"spoofDialogReloginTitle": "Готово!",
|
||||
"spoofDialogReloginContent": "Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.",
|
||||
"spoofDialogReloginWarning": "Перезайти сейчас?",
|
||||
"spoofDialogReloginDeny": "Позже",
|
||||
"spoofDialogReloginConfirm": "Перелогиниться сейчас",
|
||||
"spoofDialogApplyDeny": "Не",
|
||||
"spoofDialogApplyConfirm": "Ок!",
|
||||
"spoofErrorApplyFailed": "Ошибка при применении настроек: {error}",
|
||||
"@spoofErrorApplyFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMenuSpoof": "Подмена данных"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'tos_en.dart';
|
||||
import 'tos_ru.dart';
|
||||
|
||||
String termsOfServiceBody(Locale locale) {
|
||||
if (locale.languageCode == 'ru') {
|
||||
return kTermsOfServiceRu;
|
||||
}
|
||||
return kTermsOfServiceEn;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
const String kTermsOfServiceEn = r'''
|
||||
Terms of use for the unofficial MAX client named "KometClient" or "Komet"
|
||||
|
||||
1. Status and relationships
|
||||
1.1. "Komet" (the "App") is an unofficial third-party application and is not affiliated with Communication Platform LLC (rights holder of the "MAX" service).
|
||||
1.2. The App developers are not partners, employees, or affiliates of Communication Platform LLC.
|
||||
1.3. All references to the "MAX" trademarks and related services belong to their respective owners.
|
||||
|
||||
2. Terms of use
|
||||
2.1. By using "Komet", you:
|
||||
• Automatically agree to the official MAX User Agreement (https://legal.max.ru/ps)
|
||||
• Understand that using an unofficial client may lead to your account being blocked by Communication Platform LLC
|
||||
• Accept all risks associated with using the App
|
||||
2.2. Strictly prohibited:
|
||||
• Using "Komet" to distribute prohibited content
|
||||
• Sending bulk messages (spam)
|
||||
• Violating applicable law
|
||||
• Attempting to hack or disrupt the original "MAX" service
|
||||
2.3. The technical implementation follows fair-use principles and does not infringe the rights holder's exclusive rights under applicable interoperability and fair-use norms.
|
||||
2.4. Technical interaction:
|
||||
• "Komet" uses publicly available interaction methods with "MAX", similar to the web client (https://web.max.ru)
|
||||
• Requests are made for interoperability purposes
|
||||
• The developers do not circumvent technical protection measures or decompile the original software
|
||||
|
||||
3. Technical aspects
|
||||
3.1. "Komet" uses only publicly available methods to interact with "MAX" through official endpoints.
|
||||
3.2. Requests are made under fair use for interoperability.
|
||||
3.3. The developers are not liable for:
|
||||
• Changes to the original service API
|
||||
• Account blocks
|
||||
• Functional limitations caused by Communication Platform LLC
|
||||
|
||||
4. Privacy
|
||||
4.1. "Komet" does not store or process users' personal data on developers' servers.
|
||||
4.2. Authentication data is sent directly to Communication Platform LLC servers.
|
||||
4.3. The developers do not have access to logins, passwords, chats, or other personal user data.
|
||||
|
||||
5. Liability and limitations
|
||||
5.1. "Komet" is provided "as is" without warranties.
|
||||
5.2. The developers may discontinue support at any time without notice.
|
||||
|
||||
6. Legal basis
|
||||
6.1. Development and distribution of "Komet" are carried out with regard to interoperability, fair use, and publicly available information principles, including EU Directive 2019/790 on interoperability where applicable.
|
||||
6.2. Interaction with "MAX" occurs only through:
|
||||
• Public interfaces available via the web client
|
||||
• Reverse engineering methods permitted for interoperability purposes where applicable by law
|
||||
• Open interaction protocols not protected by technical protection measures
|
||||
6.3. "Komet" does not circumvent technical protection measures or disrupt the normal operation of the original service.
|
||||
|
||||
7. Final provisions
|
||||
7.1. By using "Komet", you agree that:
|
||||
• The authorized way to use "MAX" is through official clients
|
||||
• Service-related claims should be directed to Communication Platform LLC
|
||||
• The App developers are not liable for any direct or indirect damages
|
||||
7.2. These terms may change without prior notice.
|
||||
|
||||
8. Security and privacy features
|
||||
8.1. "Komet" may include privacy tools:
|
||||
• Session data substitution to reduce tracking via OSINT-style techniques
|
||||
• Proxy connectivity for safer network access
|
||||
• Reduced telemetry where applicable
|
||||
8.2. These features:
|
||||
• Aim to protect user privacy
|
||||
• Are not intended to bypass the original service's security systems
|
||||
• Are implemented with respect for privacy protections under applicable law
|
||||
8.3. The developers are not liable for:
|
||||
• Blocks related to privacy tools
|
||||
• Service behavior when such features are enabled
|
||||
8.4. Session export and import
|
||||
8.4.1. "Komet" may allow exporting and importing session data for:
|
||||
• Moving data between your devices
|
||||
• Backing up credentials
|
||||
• Restoring access if a device is lost
|
||||
8.4.2. Implementation notes:
|
||||
• Export may be decoupled from the phone number where technically possible
|
||||
• Session data may be protected with a password and encryption (e.g. AES-256)
|
||||
• Encryption keys are known only to you and are not stored by the developers
|
||||
8.4.3. Technical approach:
|
||||
• Export/import may use authorization tokens for service identification
|
||||
• Session parameters may be adjusted to preserve authentication context
|
||||
• Proxy settings may be included for a single connectivity configuration
|
||||
• Imported sessions may route traffic according to your proxy settings
|
||||
• The original service may not receive explicit device-change metadata beyond what the protocol normally sends
|
||||
• Encryption may apply to the full exported package (session + proxy configuration)
|
||||
8.4.4. Legal considerations:
|
||||
• Processing is based on your consent where personal data laws require it
|
||||
• Data minimization: only what is needed for operation
|
||||
• Token use is not unauthorized access under applicable criminal computer-access laws
|
||||
• Session handling is analogous to legitimate session persistence (e.g. cookies)
|
||||
• IP masking can be a lawful way to protect personal data where applicable
|
||||
8.4.5. Limitations:
|
||||
• You are responsible for passwords and backups
|
||||
• Developers cannot access your encrypted session exports
|
||||
• Lost passwords cannot be recovered by design
|
||||
• Encryption keys are not stored in the app and are known only to you
|
||||
''';
|
||||
@@ -0,0 +1,101 @@
|
||||
const String kTermsOfServiceRu = r'''
|
||||
Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"
|
||||
|
||||
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. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 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. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.
|
||||
|
||||
6. Правовые основания
|
||||
6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:
|
||||
• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;
|
||||
• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;
|
||||
• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;
|
||||
• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.
|
||||
6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:
|
||||
• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;
|
||||
• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;
|
||||
• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.
|
||||
6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.
|
||||
|
||||
7. Заключительные положения
|
||||
7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:
|
||||
• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;
|
||||
• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;
|
||||
• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.
|
||||
7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.
|
||||
|
||||
8. Функции безопасности и конфиденциальности
|
||||
8.1. Приложение «Komet» включает инструменты защиты приватности:
|
||||
• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);
|
||||
• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;
|
||||
• Ограничение телеметрии — для минимизации передачи диагностических данных.
|
||||
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. Ограничения ответственности:
|
||||
• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий
|
||||
• Разработчики не имеют доступа к зашифрованным данным сессии
|
||||
• Восстановление утерянных паролей невозможно в целях безопасности
|
||||
• Ключи шифрования не хранятся в приложении и известны только пользователю
|
||||
''';
|
||||
+239
-11
@@ -1,27 +1,148 @@
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'backend/api.dart';
|
||||
import 'backend/modules/account.dart';
|
||||
import 'backend/modules/messages.dart';
|
||||
import 'core/storage/app_database.dart';
|
||||
import 'core/storage/token_storage.dart';
|
||||
import 'core/protocol/packet.dart';
|
||||
import 'frontend/debug/fps_overlay_layer.dart';
|
||||
import 'frontend/screens/auth/login_screen.dart';
|
||||
import 'frontend/screens/chats/chat_list_screen.dart';
|
||||
import 'frontend/widgets/custom_notification.dart';
|
||||
|
||||
final api = Api();
|
||||
final accountModule = AccountModule(api);
|
||||
final messagesModule = MessagesModule(api);
|
||||
|
||||
Future<Locale> _loadInitialLocale() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final code = prefs.getString('app_locale');
|
||||
if (code != null && (code == 'en' || code == 'ru')) {
|
||||
return Locale(code);
|
||||
}
|
||||
final platform = WidgetsBinding.instance.platformDispatcher.locale;
|
||||
if (platform.languageCode == 'en' || platform.languageCode == 'ru') {
|
||||
return Locale(platform.languageCode);
|
||||
}
|
||||
return const Locale('ru');
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AppDatabase.init();
|
||||
await api.connect();
|
||||
runApp(const MyApp());
|
||||
final initialLocale = await _loadInitialLocale();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
||||
runApp(
|
||||
KometApp(
|
||||
initialLocale: initialLocale,
|
||||
initialFpsOverlay: initialFpsOverlay,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
class KometApp extends StatefulWidget {
|
||||
const KometApp({
|
||||
super.key,
|
||||
required this.initialLocale,
|
||||
this.initialFpsOverlay = false,
|
||||
});
|
||||
|
||||
final Locale initialLocale;
|
||||
final bool initialFpsOverlay;
|
||||
static final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
static KometAppState? stateOf(BuildContext context) {
|
||||
return context.findAncestorStateOfType<KometAppState>();
|
||||
}
|
||||
|
||||
@override
|
||||
State<KometApp> createState() => KometAppState();
|
||||
}
|
||||
|
||||
class KometAppState extends State<KometApp> {
|
||||
static const _fallbackSeed = Color(0xFFC1C4FF);
|
||||
|
||||
static ColorScheme _adjustScheme(ColorScheme base) {
|
||||
late Locale _locale;
|
||||
bool _isLoggingOut = false;
|
||||
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
||||
widget.initialFpsOverlay,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_locale = widget.initialLocale;
|
||||
|
||||
api.setReconnectCallback(() async {
|
||||
try {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null &&
|
||||
await TokenStorage.readToken(accountId) != null) {
|
||||
final token = await TokenStorage.readToken(accountId);
|
||||
await accountModule.login(accountId: accountId, token: token);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
api.sessionExpiredStream.listen((SessionExpiredException e) async {
|
||||
if (_isLoggingOut) return;
|
||||
_isLoggingOut = true;
|
||||
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await accountModule.removeAccount(accountId);
|
||||
}
|
||||
|
||||
final navState = KometApp.navigatorKey.currentState;
|
||||
if (navState != null) {
|
||||
final overlay = navState.overlay;
|
||||
if (overlay != null) {
|
||||
showCustomNotificationOnOverlay(overlay, e.message);
|
||||
}
|
||||
|
||||
await navState.pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
_isLoggingOut = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
fpsOverlayEnabled.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> setFpsOverlayEnabled(bool value) async {
|
||||
if (fpsOverlayEnabled.value == value) return;
|
||||
fpsOverlayEnabled.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('dev_fps_overlay', value);
|
||||
}
|
||||
|
||||
Future<void> applyLocale(Locale locale) async {
|
||||
if (!AppLocalizations.supportedLocales.any(
|
||||
(l) => l.languageCode == locale.languageCode,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('app_locale', locale.languageCode);
|
||||
if (mounted) {
|
||||
setState(() => _locale = locale);
|
||||
}
|
||||
}
|
||||
|
||||
ColorScheme _adjustDarkScheme(ColorScheme base) {
|
||||
return base.copyWith(
|
||||
surface: Color.alphaBlend(
|
||||
base.primary.withValues(alpha: 0.05),
|
||||
@@ -38,30 +159,137 @@ class MyApp extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
ColorScheme _adjustLightScheme(ColorScheme base) {
|
||||
return base.copyWith(
|
||||
surface: Color.alphaBlend(
|
||||
base.primary.withValues(alpha: 0.06),
|
||||
const Color(0xFFF5F5FA),
|
||||
),
|
||||
surfaceContainerHigh: Color.alphaBlend(
|
||||
base.primary.withValues(alpha: 0.08),
|
||||
const Color(0xFFEAEAF2),
|
||||
),
|
||||
surfaceContainerHighest: Color.alphaBlend(
|
||||
base.primary.withValues(alpha: 0.11),
|
||||
const Color(0xFFDEDEE8),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DynamicColorBuilder(
|
||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
||||
final baseScheme = darkDynamic ?? ColorScheme.fromSeed(
|
||||
seedColor: _fallbackSeed,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
final lightBase =
|
||||
lightDynamic ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: _fallbackSeed,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
final darkBase =
|
||||
darkDynamic ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: _fallbackSeed,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
|
||||
final darkScheme = _adjustScheme(baseScheme);
|
||||
final lightScheme = _adjustLightScheme(lightBase);
|
||||
final darkScheme = _adjustDarkScheme(darkBase);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Komet',
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: _locale,
|
||||
themeMode: ThemeMode.system,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: lightScheme,
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData(brightness: Brightness.light).textTheme,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: darkScheme,
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData.dark().textTheme,
|
||||
ThemeData(brightness: Brightness.dark).textTheme,
|
||||
),
|
||||
),
|
||||
home: const LoginScreen(),
|
||||
navigatorKey: KometApp.navigatorKey,
|
||||
builder: (context, child) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: fpsOverlayEnabled,
|
||||
builder: (context, fpsOn, _) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
child ?? const SizedBox.shrink(),
|
||||
if (fpsOn) const FpsOverlayLayer(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
home: const _StartupScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupScreen extends StatefulWidget {
|
||||
const _StartupScreen();
|
||||
|
||||
@override
|
||||
State<_StartupScreen> createState() => _StartupScreenState();
|
||||
}
|
||||
|
||||
class _StartupScreenState extends State<_StartupScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tryAutoLogin();
|
||||
}
|
||||
|
||||
Future<void> _tryAutoLogin() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null || await TokenStorage.readToken(accountId) == null) {
|
||||
_goToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ChatListScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await accountModule.login(accountId: accountId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _goToLogin() {
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: cs.primary, strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
enum AttachmentType {
|
||||
photo,
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
contact,
|
||||
location,
|
||||
sticker,
|
||||
control,
|
||||
}
|
||||
|
||||
abstract class MessageAttachment {
|
||||
final AttachmentType type;
|
||||
final String? previewData;
|
||||
final String? baseUrl;
|
||||
final String? fileUrl;
|
||||
|
||||
const MessageAttachment({
|
||||
required this.type,
|
||||
this.previewData,
|
||||
this.baseUrl,
|
||||
this.fileUrl,
|
||||
});
|
||||
|
||||
factory MessageAttachment.fromMap(Map<String, dynamic> map) {
|
||||
final type = (map['_type'] as String? ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case 'PHOTO':
|
||||
return PhotoAttachment.fromMap(map);
|
||||
case 'VIDEO':
|
||||
return VideoAttachment.fromMap(map);
|
||||
case 'AUDIO':
|
||||
return AudioAttachment.fromMap(map);
|
||||
case 'FILE':
|
||||
return FileAttachment.fromMap(map);
|
||||
case 'STICKER':
|
||||
return StickerAttachment.fromMap(map);
|
||||
case 'CONTACT':
|
||||
return ContactAttachment.fromMap(map);
|
||||
case 'LOCATION':
|
||||
return LocationAttachment.fromMap(map);
|
||||
case 'CONTROL':
|
||||
return ControlAttachment.fromMap(map);
|
||||
case 'SHARE':
|
||||
return FileAttachment.fromMap(map);
|
||||
case 'INLINE_KEYBOARD':
|
||||
return UnknownAttachment(map);
|
||||
default:
|
||||
return UnknownAttachment(map);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap();
|
||||
}
|
||||
|
||||
class PhotoAttachment extends MessageAttachment {
|
||||
final int? photoId;
|
||||
final String? photoToken;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? size;
|
||||
|
||||
const PhotoAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.photoId,
|
||||
this.photoToken,
|
||||
this.width,
|
||||
this.height,
|
||||
this.size,
|
||||
}) : super(type: AttachmentType.photo);
|
||||
|
||||
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
previewStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return PhotoAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
photoId: map['photoId'] as int?,
|
||||
photoToken: map['photoToken'] as String?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
size: map['size'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'PHOTO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'photoId': photoId,
|
||||
'photoToken': photoToken,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
class VideoAttachment extends MessageAttachment {
|
||||
final int? videoId;
|
||||
final String? videoToken;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? duration;
|
||||
final int? size;
|
||||
|
||||
const VideoAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.videoId,
|
||||
this.videoToken,
|
||||
this.width,
|
||||
this.height,
|
||||
this.duration,
|
||||
this.size,
|
||||
}) : super(type: AttachmentType.video);
|
||||
|
||||
factory VideoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
previewStr = String.fromCharCodes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return VideoAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
videoId: map['videoId'] as int?,
|
||||
videoToken: map['videoToken'] as String?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
duration: map['duration'] as int?,
|
||||
size: map['size'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'VIDEO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'videoId': videoId,
|
||||
'videoToken': videoToken,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'duration': duration,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
class AudioAttachment extends MessageAttachment {
|
||||
final int? audioId;
|
||||
final String? audioToken;
|
||||
final int? duration;
|
||||
final int? size;
|
||||
final String? waveform;
|
||||
|
||||
const AudioAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.audioId,
|
||||
this.audioToken,
|
||||
this.duration,
|
||||
this.size,
|
||||
this.waveform,
|
||||
}) : super(type: AttachmentType.audio);
|
||||
|
||||
factory AudioAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
previewStr = String.fromCharCodes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return AudioAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
audioId: map['audioId'] as int?,
|
||||
audioToken: map['audioToken'] as String?,
|
||||
duration: map['duration'] as int?,
|
||||
size: map['size'] as int?,
|
||||
waveform: map['waveform'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'AUDIO',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'audioId': audioId,
|
||||
'audioToken': audioToken,
|
||||
'duration': duration,
|
||||
'size': size,
|
||||
'waveform': waveform,
|
||||
};
|
||||
}
|
||||
|
||||
class FileAttachment extends MessageAttachment {
|
||||
final int? fileId;
|
||||
final String? fileToken;
|
||||
final String? name;
|
||||
final int? size;
|
||||
|
||||
const FileAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.fileId,
|
||||
this.fileToken,
|
||||
this.name,
|
||||
this.size,
|
||||
}) : super(type: AttachmentType.file);
|
||||
|
||||
factory FileAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
previewStr = String.fromCharCodes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return FileAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
fileId: map['fileId'] as int?,
|
||||
fileToken: map['fileToken'] as String?,
|
||||
name: map['name'] as String?,
|
||||
size: map['size'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'FILE',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'fileId': fileId,
|
||||
'fileToken': fileToken,
|
||||
'name': name,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
class StickerAttachment extends MessageAttachment {
|
||||
final String? stickerId;
|
||||
final String? stickerPackId;
|
||||
final int? width;
|
||||
final int? height;
|
||||
|
||||
const StickerAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.stickerId,
|
||||
this.stickerPackId,
|
||||
this.width,
|
||||
this.height,
|
||||
}) : super(type: AttachmentType.sticker);
|
||||
|
||||
factory StickerAttachment.fromMap(Map<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(previewRaw);
|
||||
previewStr = String.fromCharCodes(bytes);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return StickerAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
stickerId: map['stickerId'] as String?,
|
||||
stickerPackId: map['stickerPackId'] as String?,
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'STICKER',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'stickerId': stickerId,
|
||||
'stickerPackId': stickerPackId,
|
||||
'width': width,
|
||||
'height': height,
|
||||
};
|
||||
}
|
||||
|
||||
class ContactAttachment extends MessageAttachment {
|
||||
final String? userId;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? phoneNumber;
|
||||
final String? photoUrl;
|
||||
final int? contactId;
|
||||
final String? name;
|
||||
|
||||
const ContactAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.userId,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.phoneNumber,
|
||||
this.photoUrl,
|
||||
this.contactId,
|
||||
this.name,
|
||||
}) : super(type: AttachmentType.contact);
|
||||
|
||||
factory ContactAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return ContactAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
userId: map['userId'] as String?,
|
||||
firstName: map['firstName'] as String?,
|
||||
lastName: map['lastName'] as String?,
|
||||
phoneNumber: map['phoneNumber'] as String?,
|
||||
photoUrl: map['photoUrl'] as String?,
|
||||
contactId: map['contactId'] as int?,
|
||||
name: map['name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'CONTACT',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'userId': userId,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'phoneNumber': phoneNumber,
|
||||
'photoUrl': photoUrl,
|
||||
'contactId': contactId,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
|
||||
class LocationAttachment extends MessageAttachment {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? title;
|
||||
final String? address;
|
||||
|
||||
const LocationAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.title,
|
||||
this.address,
|
||||
}) : super(type: AttachmentType.location);
|
||||
|
||||
factory LocationAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return LocationAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
latitude: (map['latitude'] as num?)?.toDouble(),
|
||||
longitude: (map['longitude'] as num?)?.toDouble(),
|
||||
title: map['title'] as String?,
|
||||
address: map['address'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'LOCATION',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'title': title,
|
||||
'address': address,
|
||||
};
|
||||
}
|
||||
|
||||
class ControlAttachment extends MessageAttachment {
|
||||
final String? event;
|
||||
final String? title;
|
||||
final List<int>? userIds;
|
||||
|
||||
const ControlAttachment({
|
||||
super.previewData,
|
||||
super.baseUrl,
|
||||
super.fileUrl,
|
||||
this.event,
|
||||
this.title,
|
||||
this.userIds,
|
||||
}) : super(type: AttachmentType.control);
|
||||
|
||||
factory ControlAttachment.fromMap(Map<String, dynamic> map) {
|
||||
return ControlAttachment(
|
||||
previewData: map['previewData'] as String?,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
event: map['event'] as String?,
|
||||
title: map['title'] as String?,
|
||||
userIds: (map['userIds'] as List?)?.cast<int>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'CONTROL',
|
||||
'previewData': previewData,
|
||||
'baseUrl': baseUrl,
|
||||
'event': event,
|
||||
'title': title,
|
||||
'userIds': userIds,
|
||||
};
|
||||
}
|
||||
|
||||
class ForwardedMessageAttachment extends MessageAttachment {
|
||||
final int originalSenderId;
|
||||
final String? originalSenderName;
|
||||
final String? originalSenderAvatar;
|
||||
final String? originalMessageId;
|
||||
final int? originalTime;
|
||||
final String? originalText;
|
||||
final int? originalChatId;
|
||||
final List<MessageAttachment>? originalAttachments;
|
||||
final ContactAttachment? originalContact;
|
||||
|
||||
const ForwardedMessageAttachment({
|
||||
required this.originalSenderId,
|
||||
this.originalSenderName,
|
||||
this.originalSenderAvatar,
|
||||
this.originalMessageId,
|
||||
this.originalTime,
|
||||
this.originalText,
|
||||
this.originalChatId,
|
||||
this.originalAttachments,
|
||||
this.originalContact,
|
||||
}) : super(type: AttachmentType.photo);
|
||||
|
||||
factory ForwardedMessageAttachment.fromMap(Map<String, dynamic> map) {
|
||||
final linkRaw = map['link'];
|
||||
Map<String, dynamic>? link;
|
||||
if (linkRaw is Map) {
|
||||
link = Map<String, dynamic>.from(linkRaw);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? message;
|
||||
if (link != null) {
|
||||
final msgRaw = link['message'];
|
||||
if (msgRaw is Map) {
|
||||
message = Map<String, dynamic>.from(msgRaw);
|
||||
}
|
||||
}
|
||||
|
||||
List<MessageAttachment>? originalAttaches;
|
||||
ContactAttachment? originalContact;
|
||||
if (message != null) {
|
||||
final attaches = message['attaches'] as List?;
|
||||
if (attaches != null) {
|
||||
final contactAttaches = attaches
|
||||
.whereType<Map>()
|
||||
.where((a) => (a['_type'] as String?)?.toUpperCase() == 'CONTACT')
|
||||
.toList();
|
||||
if (contactAttaches.isNotEmpty) {
|
||||
originalContact = ContactAttachment.fromMap(
|
||||
Map<String, dynamic>.from(contactAttaches.first),
|
||||
);
|
||||
}
|
||||
originalAttaches = attaches
|
||||
.whereType<Map>()
|
||||
.where((a) {
|
||||
final type = (a['_type'] as String?)?.toUpperCase();
|
||||
return type != 'CONTACT';
|
||||
})
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
return ForwardedMessageAttachment(
|
||||
originalSenderId: (message?['sender'] as int?) ?? 0,
|
||||
originalMessageId: message?['id']?.toString(),
|
||||
originalTime: message?['time'] as int?,
|
||||
originalText: message?['text'] as String?,
|
||||
originalChatId: link?['chatId'] as int?,
|
||||
originalAttachments: originalAttaches,
|
||||
originalContact: originalContact,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'_type': 'FORWARD',
|
||||
'originalSenderId': originalSenderId,
|
||||
'originalSenderName': originalSenderName,
|
||||
'originalSenderAvatar': originalSenderAvatar,
|
||||
'originalMessageId': originalMessageId,
|
||||
'originalTime': originalTime,
|
||||
'originalText': originalText,
|
||||
'originalChatId': originalChatId,
|
||||
};
|
||||
}
|
||||
|
||||
class UnknownAttachment extends MessageAttachment {
|
||||
final Map<String, dynamic> rawData;
|
||||
|
||||
const UnknownAttachment(this.rawData) : super(type: AttachmentType.photo);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => rawData;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
PODS:
|
||||
- device_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- dynamic_color (0.0.2):
|
||||
- FlutterMacOS
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_timezone (0.1.0):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||
- dynamic_color (from `Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos`)
|
||||
- flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
device_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||
dynamic_color:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos
|
||||
flutter_secure_storage_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin
|
||||
flutter_timezone:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
shared_preferences_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||
sqflite_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
|
||||
dynamic_color: 5fdff3953fb3457311091863f72914fc76ea3209
|
||||
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
flutter_timezone: b3bc0c587d8780d395651284a1ff46eb1e5753ac
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -21,12 +21,14 @@
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1B0AAF467EC25EF9159DC6E7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */; };
|
||||
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 */; };
|
||||
5891C53C95FBB6606F87B07B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -60,11 +62,13 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
2F1D1037B18DFACF3DCB068D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* komet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "komet.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10ED2044A3C60003C045 /* komet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = komet.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
@@ -77,7 +81,13 @@
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
A30CA7B4319C9569917A735D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D9473A96CB79E1B69ACF81CC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -85,6 +95,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5891C53C95FBB6606F87B07B /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -92,12 +103,27 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1B0AAF467EC25EF9159DC6E7 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1DB0FC36071C177710C75E9D /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D9473A96CB79E1B69ACF81CC /* Pods-Runner.debug.xcconfig */,
|
||||
A30CA7B4319C9569917A735D /* Pods-Runner.release.xcconfig */,
|
||||
2F1D1037B18DFACF3DCB068D /* Pods-Runner.profile.xcconfig */,
|
||||
9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */,
|
||||
CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -125,6 +151,7 @@
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
1DB0FC36071C177710C75E9D /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -175,6 +202,8 @@
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */,
|
||||
BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -186,6 +215,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
5D8614419BBD7BD81C481542 /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
@@ -204,11 +234,13 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
C78DF3AD05984D1177E03977 /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
AAC6FCD26EB13D7261C78A3C /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -329,6 +361,67 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
5D8614419BBD7BD81C481542 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
AAC6FCD26EB13D7261C78A3C /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
C78DF3AD05984D1177E03977 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -380,6 +473,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -394,6 +488,7 @@
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -408,6 +503,7 @@
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
+47
-10
@@ -21,10 +21,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -77,10 +77,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
|
||||
sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.4.0"
|
||||
version: "12.3.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -142,6 +142,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -248,6 +253,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -300,18 +313,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
material_symbols_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -328,6 +341,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
msgpack_dart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -352,6 +373,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.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:
|
||||
@@ -601,10 +638,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+7
-1
@@ -30,6 +30,9 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: any
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
@@ -37,7 +40,7 @@ dependencies:
|
||||
dart_lz4: ^1.0.0
|
||||
msgpack_dart: ^1.0.1
|
||||
logger: ^2.6.2
|
||||
device_info_plus: ^12.3.0
|
||||
device_info_plus: 12.3.0
|
||||
flutter_timezone: ^5.0.1
|
||||
timezone: ^0.11.0
|
||||
flutter_secure_storage: ^10.0.0
|
||||
@@ -48,6 +51,8 @@ dependencies:
|
||||
material_symbols_icons: ^4.2906.0
|
||||
dynamic_color: ^1.8.1
|
||||
shared_preferences: ^2.5.4
|
||||
package_info_plus: ^9.0.1
|
||||
mobile_scanner: ^7.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -65,6 +70,7 @@ dev_dependencies:
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
generate: true
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
|
||||
+20
-24
@@ -1,30 +1,26 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:komet/main.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
testWidgets('AppLocalizations Russian login title', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('ru'),
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
body: Text(AppLocalizations.of(context)!.loginTitle),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Komet'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user