Merge pull request #14 from KometTeam/feature/FullStack

Feature/full stack
This commit is contained in:
Jganenok
2026-04-19 12:02:09 +07:00
committed by GitHub
72 changed files with 17719 additions and 1736 deletions
+91
View File
@@ -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
+2 -1
View File
@@ -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"
}
+5
View File
@@ -0,0 +1,5 @@
Не оставляй комментарии в код
Старайся писать чистый код
Лучше качество чем количество
Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно.
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
+3
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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:
+62
View File
@@ -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
+112
View File
@@ -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
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+2
View File
@@ -45,5 +45,7 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере.</string>
</dict>
</plist>
+4
View File
@@ -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
View File
@@ -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);
+145
View File
@@ -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,
};
}
+629 -42
View File
@@ -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));
}
}
}
+135
View File
@@ -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
View File
@@ -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?;
}
}
+97
View File
@@ -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,
};
}
}
+198
View File
@@ -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);
}
}
}
+396
View File
@@ -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;
}
}
+20 -2
View File
@@ -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);
}
}
+11 -2
View File
@@ -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
+77
View File
@@ -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);
}
}
-31
View File
@@ -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';
}
+3 -3
View File
@@ -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',
+84 -55
View File
@@ -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-заголовка).
+223 -40
View File
@@ -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],
);
}
}
+26
View File
@@ -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,
};
}
}
+21 -16
View File
@@ -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);
}
}
}
+35 -8
View File
@@ -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 (_) {}
}
+13 -4
View File
@@ -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);
}
+365
View File
@@ -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();
}
}
+7 -9
View File
@@ -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() {
+3 -4
View File
@@ -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
View File
@@ -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();
}
}
+145
View File
@@ -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,
),
),
],
+243 -141
View File
@@ -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'
'• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов OpenSourceIntelligence (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,
),
),
),
],
);
}
}
+330 -9
View File
@@ -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
+415 -85
View File
@@ -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,
),
),
),
],
+185 -9
View File
@@ -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
+390 -45
View File
@@ -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
+108
View File
@@ -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 (165535)",
"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 (165535)",
"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"
}
+668
View File
@@ -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, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects 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 (165535)'**
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 (165535)'**
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.',
);
}
+292
View File
@@ -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 (165535)';
@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 (165535)';
@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';
}
+294
View File
@@ -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 => 'Подмена данных';
}
+108
View File
@@ -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": "Подмена данных"
}
+11
View File
@@ -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;
}
+96
View File
@@ -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
''';
+101
View File
@@ -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» включает инструменты защиты приватности:
Подмена данных сессии для предотвращения отслеживания пользователя с помощью продвинутых инструментов OpenSourceIntelligence (OSINT);
Система проксиподключений для обеспечения безопасности сетевого взаимодействия;
Ограничение телеметрии для минимизации передачи диагностических данных.
8.2. Данные функции:
Направлены исключительно на защиту конфиденциальности пользователей;
Не используются для обхода систем безопасности оригинального сервиса;
Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.
8.3. Разработчики не несут ответственности за:
Блокировки, связанные с использованием инструментов конфиденциальности;
Изменения в работе сервиса при активации данных функций.
8.4. Функции экспорта и импорта сессии
8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:
Обеспечения переносимости данных между устройствами пользователя
Резервного копирования учетных данных
Восстановления доступа при утере устройства
8.4.2. Особенности реализации:
Экспорт сессии осуществляется без привязки к номеру телефона
Данные сессии защищаются паролем и шифрованием по алгоритмам AES256
Ключ шифрования известен только пользователю и не сохраняется в приложении
8.4.3. Техническая реализация экспорта сессии:
Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе
Используется подмена параметров сессии для сохранения контекста аутентификации
Интеграция настроек прокси для обеспечения единой конфигурации подключения
Импортированная сессия маскирует источник подключения через указанные проксинастройки
Серверы оригинального сервиса не получают данных о смене устройства пользователя
Шифрование применяется ко всему пакету данных (сессия + проксиконфиг)
8.4.4. Правовые основания:
Статья 6 ФЗ152 «О персональных данных» обработка данных с согласия субъекта
Статья 434 ГК РФ право на выбор формы сделки (электронная форма хранения учетных данных)
Принцип минимизации данных сбор только необходимой для работы информации
Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)
Подмена сессии легитимный метод сохранения аутентификации (аналог браузерных cookies)
Маскировка IPадреса законный способ защиты персональных данных (ст. 6 ФЗ152)
8.4.5. Ограничения ответственности:
Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий
Разработчики не имеют доступа к зашифрованным данным сессии
Восстановление утерянных паролей невозможно в целях безопасности
Ключи шифрования не хранятся в приложении и известны только пользователю
''';
+239 -11
View File
@@ -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),
),
);
}
}
+542
View File
@@ -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;
}
+55
View File
@@ -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
+97 -1
View File
@@ -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;
+3
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+47 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
}