diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml new file mode 100644 index 0000000..380cd38 --- /dev/null +++ b/.github/workflows/build-android.yml @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 08303a5..31f08e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c894244 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +Не оставляй комментарии в код +Старайся писать чистый код +Лучше качество чем количество +Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно. +ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c8fd234..b2c538d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + /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; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7014b43..a1199c7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,5 +45,7 @@ UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере. diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..1437ccc --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/lib/backend/api.dart b/lib/backend/api.dart index f014ad8..2c50950 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -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.broadcast(); + final _sessionExpiredController = + StreamController.broadcast(); + final _handshakeSuccessController = StreamController.broadcast(); + Map? _userAgent; + + Map? get userAgent => _userAgent; + + List? _registrationCountries; + + List get registrationCountries => + _registrationCountries ?? allCountries; Stream get stateStream => _stateController.stream; + Stream get sessionExpiredStream => + _sessionExpiredController.stream; + Stream get handshakeSuccessStream => + _handshakeSuccessController.stream; SessionState get state => _sessionState; StreamSubscription? _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 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 = { '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 sendRequest( - int opcode, - Map payload, - ) { + Future sendRequest(int opcode, Map 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 _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 reconnectAndLogin() async { + await connect(); + } + + void Function()? _onReconnectCallback; + + void setReconnectCallback(void Function() callback) { + _onReconnectCallback = callback; } void _startPinging() { @@ -242,6 +309,28 @@ class Api { }); } + static List? _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 = []; + 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); diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart new file mode 100644 index 0000000..0d6044a --- /dev/null +++ b/lib/backend/models/chat_folder.dart @@ -0,0 +1,145 @@ +class ChatFolder { + final String id; + final String title; + final String? emoji; + final List? include; + final List filters; + final bool hideEmpty; + final List widgets; + final List? favorites; + final Map? filterSubjects; + final List? options; + + ChatFolder({ + required this.id, + required this.title, + this.emoji, + this.include, + required this.filters, + required this.hideEmpty, + required this.widgets, + this.favorites, + this.filterSubjects, + this.options, + }); + + factory ChatFolder.fromJson(Map json) { + return ChatFolder( + id: json['id'].toString(), + title: json['title']?.toString() ?? '', + emoji: json['emoji']?.toString(), + include: (json['include'] as List?) + ?.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?) + ?.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?) + ?.map((w) { + if (w is Map) { + return ChatFolderWidget.fromJson(w); + } + return ChatFolderWidget.fromJson( + Map.from(w as Map), + ); + }) + .toList() ?? + [], + favorites: (json['favorites'] as List?) + ?.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 + ? json['filterSubjects'] as Map + : (json['filterSubjects'] is Map + ? Map.from( + (json['filterSubjects'] as Map).cast(), + ) + : null), + options: (json['options'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }) + .toList(), + ); + } + + Map 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 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 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, + }; +} diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 57ce448..1f57233 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -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 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; + 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 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() : 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 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 raw; const LoginResult({ required this.profile, required this.updatedToken, required this.serverTime, + required this.raw, }); } class AccountModule { final Api _api; + final _loginStatusController = StreamController.broadcast(); AccountModule(this._api); + Stream get loginStatusStream => _loginStatusController.stream; + + Future 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> 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((c) => BlockedContact.fromMap(c.cast())) + .toList(); + } + + Future updatePrivacyConfig( + Map settings, + ) async { + _ensureOnline(); + final payload = { + '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()); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.savePrivacyConfig(accountId, config.toJson()); + } + return config; + } + + // 2FA Creation (when not set) + Future 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 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 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 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 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 confirm2fa({ + required String trackId, + required String password, + String? hint, + }) async { + _ensureOnline(); + final payload = { + '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 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 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 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 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 = { + '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 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 = { + 'expectedCapabilities': [4], + 'trackId': trackId, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'update2faEmail'); + return _processProfileUpdate(packet); + } + + Future remove2fa(String trackId) async { + _ensureOnline(); + final payload = { + 'expectedCapabilities': [5], + 'trackId': trackId, + 'remove2fa': true, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'remove2fa'); + return _processProfileUpdate(packet); + } + + Future _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()); + } + } + } + } + throw Exception('Не удалось получить обновлённый профиль'); + } + Future requestCode( String phone, { String language = 'ru', - }) => - _requestCodeInternal(phone, AuthRequestType.startAuth, language); + }) => _requestCodeInternal(phone, AuthRequestType.startAuth, language); Future resendCode( String phone, { String language = 'ru', - }) => - _requestCodeInternal(phone, AuthRequestType.resend, language); + }) => _requestCodeInternal(phone, AuthRequestType.resend, language); Future 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()); @@ -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(), + resolvedAccountId, + ); + _loginStatusController.add(LoginStatus.success); + return result; + } catch (e) { + _loginStatusController.add(LoginStatus.error); + rethrow; + } + } + Future> 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)) + .toList(); + } + + Future terminateOtherSessions() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.sessionsClose, {}); + _checkPacketError(packet, 'terminateOtherSessions'); + } + + Future authorizeWebQrLogin(String qrLink) async { + _ensureOnline(); + final link = qrLink.trim(); + if (link.isEmpty) { + throw ArgumentError('Пустая ссылка из QR'); } - return _processLoginResponse( - data.cast(), - resolvedAccountId, - ); + await _api.sendRequest(Opcode.ping, {'interactive': true}); + await _api.sendRequest(Opcode.sessionsInfo, {}); + await Future.delayed(const Duration(milliseconds: 300)); + final packet = await _api.sendRequest(Opcode.authQrApprove, { + 'qrLink': link, + }); + _checkPacketError(packet, 'authorizeWebQrLogin'); } Future switchAccount(int accountId) async { @@ -233,13 +804,6 @@ class AccountModule { logger.i('Аккаунт $accountId удалён локально'); } - /// Проверяет 2FA-пароль (opcode 115). - /// - /// [trackId] — из [VerifyCodeResult.challengeTrackId]. - /// [accountId] — из [VerifyCodeResult.accountId]. - /// - /// При неверном пароле бросает [Exception]. - /// При успехе сохраняет токен и устанавливает аккаунт активным. Future 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 = { '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()); 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(), + ); + } + 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 = { - '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)); } } } diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index e69de29..4593e48 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -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> 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; + return parseHistoryPayload(payload, accountId, currentUserId); + } + + /// Парсинг истории звонков (opcode 79: videoChatHistory) + static Future> parseHistoryPayload( + Map 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 extractedCalls = []; + + for (final item in history.whereType()) { + 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 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; + } + } +} diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index d9c7d91..3b0fb27 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -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 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 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.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v)) + ); Map 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((c) => _parseChat( + final rows = chats + .whereType() + .map( + (c) => _parseChat( c.cast(), accountId, currentUserId, contactsMap, + chatsConfig, + presenceMap, existing, cachedAt, - )) - .whereType() - .map((c) => c.toDbRow()) - .toList(); + ), + ) + .whereType() + .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> 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> 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 clearCache(int accountId) => AppDatabase.clearChatsCache(accountId); - // internal - static Map> _buildContactsMap(dynamic contacts) { if (contacts is! List) return {}; final result = >{}; @@ -126,61 +181,93 @@ class ChatsModule { int accountId, int currentUserId, Map> contactsMap, + Map chatsConfig, + Map presenceMap, Map 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 participants = Map.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 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?; } } diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart new file mode 100644 index 0000000..1943e52 --- /dev/null +++ b/lib/backend/modules/contacts.dart @@ -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 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 syncFromLoginPayload( + Map data, + int accountId, + ) async { + final contacts = data['contacts']; + if (contacts is! List || contacts.isEmpty) return; + + final rows = contacts + .whereType() + .map((c) => _parseContact(c.cast(), accountId)) + .whereType>() + .toList(); + + if (rows.isNotEmpty) { + await AppDatabase.saveContacts(rows); + } + } + + static Future> getContacts(int accountId) async { + final rows = await AppDatabase.loadContacts(accountId); + return rows.map(CachedContact.fromDbRow).toList(); + } + + static Map? _parseContact( + Map 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, + }; + } +} diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart new file mode 100644 index 0000000..7f66fd2 --- /dev/null +++ b/lib/backend/modules/folders.dart @@ -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 markFoldersListReady(int accountId) async { + await AppDatabase.setSyncValue(accountId, _listReadyKey, '1'); + } + + static Future 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 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 folders, + List? 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> loadFolders(int accountId) async { + final raw = await AppDatabase.getSyncValue(accountId, _syncKey); + if (raw == null || raw.isEmpty) return []; + try { + final map = jsonDecode(raw) as Map; + final folders = (map['folders'] as List?) + ?.map((e) { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + }) + .toList() ?? + []; + final order = map['foldersOrder'] as List?; + sortFoldersInPlace(folders, order); + return folders; + } catch (_) { + return []; + } + } + + static Future _persist( + int accountId, + List folders, + List? order, + ) async { + await AppDatabase.setSyncValue( + accountId, + _syncKey, + jsonEncode({ + 'folders': folders.map((f) => f.toJson()).toList(), + 'foldersOrder': order, + }), + ); + } + + static Future applyPayload( + int accountId, + Map payload, + ) async { + final foldersJson = payload['folders'] as List?; + final order = payload['foldersOrder'] as List?; + if (foldersJson == null && order == null) return; + + List folders; + if (foldersJson != null) { + folders = foldersJson + .map((json) { + try { + final m = json is Map + ? json + : Map.from(json as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } else { + folders = await loadFolders(accountId); + } + sortFoldersInPlace(folders, order); + await _persist(accountId, folders, order); + } + + static Future applyFromLoginConfig( + int accountId, + Map config, + ) async { + final chatFolders = config['chatFolders']; + if (chatFolders is! Map) return; + final foldersJson = chatFolders['FOLDERS'] as List?; + if (foldersJson == null) return; + final order = chatFolders['foldersOrder'] as List?; + final folders = foldersJson + .map((json) { + try { + final m = json is Map + ? json + : Map.from(json as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + sortFoldersInPlace(folders, order); + await _persist(accountId, folders, order); + await markFoldersListReady(accountId); + } + + static Future 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()); + } + } finally { + await markFoldersListReady(accountId); + } + } +} diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e69de29..00dd1c0 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -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 _nameCache = {}; + static final Map _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? payload; + final List? 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 row) { + Map? payload; + final payloadRaw = row['payload']; + if (payloadRaw is String && payloadRaw.isNotEmpty) { + try { + payload = jsonDecode(payloadRaw) as Map; + } catch (_) {} + } + + List? 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.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 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> 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 results = []; + final List> rows = []; + + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + + final msg = _parseMessage(m.cast(), 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> 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 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? attachments; + if (linkType == 'FORWARD') { + final fwdMap = Map.from(m.cast()); + attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; + } else { + final attaches = m['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.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.from(m.cast()), + attachments: attachments, + ); + } + + Future 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 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 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 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 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 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 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 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; + } +} diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart index 4ef18b3..c0c97cc 100644 --- a/lib/core/config/config.dart +++ b/lib/core/config/config.dart @@ -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); + } } diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index d19d055..e4120c3 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -33,6 +33,15 @@ final Map countriesByCode = { for (final country in allCountries) country.code: country, }; +List countriesInServerOrder(Iterable codes) { + final out = []; + 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": ["", " ", " ", ""]}, diff --git a/lib/core/config/device_presets.dart b/lib/core/config/device_presets.dart new file mode 100644 index 0000000..de7506e --- /dev/null +++ b/lib/core/config/device_presets.dart @@ -0,0 +1,1013 @@ +class DevicePreset { + final String deviceType; + final String userAgent; + final String deviceName; + final String osVersion; + final String screen; + final String timezone; + final String locale; + + DevicePreset({ + required this.deviceType, + required this.userAgent, + required this.deviceName, + required this.osVersion, + required this.screen, + required this.timezone, + required this.locale, + }); +} + +final List devicePresets = [ + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 8 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 23021RAA2Y) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 13 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; CPH2521) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 12', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S21 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 6', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3371) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Master Edition', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; M2101K6G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3', + osVersion: 'Android 11', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SO-51D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Sony Xperia 1 V', + osVersion: 'Android 14', + screen: 'xxxhdpi 560dpi 1644x3840', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 30 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A54', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2340', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; 2201116SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Redmi Note 11 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; ZS676KS) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Asus ROG Phone 6', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2448', + timezone: 'Asia/Taipei', + locale: 'zh-TW', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; TA-1021) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36', + deviceName: 'Nokia 8', + osVersion: 'Android 10', + screen: 'xxhdpi 380dpi 1440x2560', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; PGT-N19) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Huawei P60 Pro', + osVersion: 'Android 13 (EMUI)', + screen: 'xxhdpi 430dpi 1220x2700', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; LM-G710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Mobile Safari/537.36', + deviceName: 'LG G7 ThinQ', + osVersion: 'Android 9', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Nothing Phone (2)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Nothing Phone (2)', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2412', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-F936U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Fold 4', + osVersion: 'Android 13', + screen: 'xhdpi 350dpi 1812x2176', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; LE2113) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 7a', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; M2101K6C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3 GT', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; V29) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Vivo V29', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; K30 Ultra) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi K30 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; P80) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Oppo Find N3', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1440x3168', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G916B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S20 FE', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; CPH2135) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 8 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; S24E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Edge', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; LE2120) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3216', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; A14 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 9 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 21091116AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 12T', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; SM-F711B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Flip 3', + osVersion: 'Android 12', + screen: 'xhdpi 370dpi 1080x2640', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 40', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; 23088RA9AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 14 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; CPH2487) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 11', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3216', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; M2004J19C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi Mi 10T Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A55', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3761) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Neo 3', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro Max', + osVersion: 'iOS 17.5.1', + screen: '1290x2796 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13', + osVersion: 'iOS 16.7', + screen: '1170x2532 3.0x', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 11-inch', + osVersion: 'iPadOS 17.5', + screen: '1668x2388 2.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148', + deviceName: 'iPhone 14 Pro', + osVersion: 'iOS 17.4.1', + screen: '1179x2556 3.0x', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone SE (2020)', + osVersion: 'iOS 15.8', + screen: '750x1334 2.0x', + timezone: 'Australia/Melbourne', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPR/55.0.2519.144889 Mobile/15E148', + deviceName: 'iPhone 15', + osVersion: 'iOS 17.1', + screen: '1179x2556 3.0x', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 5th Gen', + osVersion: 'iPadOS 16.5', + screen: '1640x2360 2.0x', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro', + osVersion: 'iOS 17.5', + screen: '1179x2556 3.0x', + timezone: 'Asia/Singapore', + locale: 'en-SG', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 14', + osVersion: 'iOS 17.4', + screen: '1170x2532 3.0x', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 Pro Max', + osVersion: 'iOS 16.6', + screen: '1284x2778 3.0x', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11 Pro', + osVersion: 'iOS 15.7', + screen: '1125x2436 3.0x', + timezone: 'Europe/Istanbul', + locale: 'tr-TR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Mini 6th Gen', + osVersion: 'iPadOS 17.5', + screen: '1488x2266 2.0x', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1', + deviceName: 'iPad 10th Gen', + osVersion: 'iPadOS 16.7', + screen: '1620x2160 2.0x', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 Pro', + osVersion: 'iOS 17.3', + screen: '1170x2532 3.0x', + timezone: 'Europe/Dublin', + locale: 'en-IE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12', + osVersion: 'iOS 16.5', + screen: '1125x2436 3.0x', + timezone: 'Asia/Mumbai', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 12.9-inch', + osVersion: 'iPadOS 15.8', + screen: '2048x2732 2.0x', + timezone: 'Europe/Vienna', + locale: 'de-AT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XS Max', + osVersion: 'iOS 14.8', + screen: '1125x2436 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 mini', + osVersion: 'iOS 17.2', + screen: '1080x2340 3.0x', + timezone: 'America/Miami', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 4th Gen', + osVersion: 'iPadOS 14.8', + screen: '1640x2360 2.0x', + timezone: 'Europe/Zurich', + locale: 'de-CH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11', + osVersion: 'iOS 16.4', + screen: '828x1792 2.0x', + timezone: 'America/Argentina/Buenos_Aires', + locale: 'es-AR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 mini', + osVersion: 'iOS 17.1', + screen: '1080x2340 3.0x', + timezone: 'Europe/Brussels', + locale: 'nl-BE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air Pro 11-inch', + osVersion: 'iPadOS 17.4', + screen: '2388x1668 2.0x', + timezone: 'Asia/Bangkok', + locale: 'en-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XR', + osVersion: 'iOS 13.7', + screen: '828x1792 2.0x', + timezone: 'Europe/Lisbon', + locale: 'pt-PT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPad (9th generation)', + osVersion: 'iPadOS 17.3', + screen: '1620x2160 2.0x', + timezone: 'Europe/Prague', + locale: 'cs-CZ', + ), + + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Windows PC', + osVersion: 'Windows 11', + screen: '1920x1080 1.25x', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'MacBook Pro', + osVersion: 'macOS 14.5 Sonoma', + screen: '1728x1117 2.0x', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Linux PC', + osVersion: 'Ubuntu 24.04 LTS', + screen: '2560x1440 1.0x', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Windows PC (Firefox)', + osVersion: 'Windows 10', + screen: '1536x864 1.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15', + deviceName: 'iMac (Safari)', + osVersion: 'macOS 13.6 Ventura', + screen: '3840x2160 1.5x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows ', + screen: '1920x1080', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '2560x1440', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.2420.97', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1536x864', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1920x1200', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1440x900', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1600x900', + timezone: 'Europe/Warsaw', + locale: 'pl-PL', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.109', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 14.5', + screen: '2560x1440', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 13.6', + screen: '1440x900', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_7_10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 11.7', + screen: '1728x1117', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 12.5', + screen: '2048x1152', + timezone: 'Europe/London', + locale: 'en-GB', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'macOS 14.5', + screen: '1920x1080', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'macOS 13.0', + screen: '1680x1050', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 14.5', + screen: '1440x900', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 13.6', + screen: '2560x1600', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.14', + screen: '1280x800', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Chrome OS', + screen: '1920x1080', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1600x900', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '2560x1440', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0', + deviceName: 'Opera', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Oslo', + locale: 'no-NO', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Vivaldi/6.5.3206.63', + deviceName: 'Vivaldi', + osVersion: 'macOS 14.0', + screen: '1440x900', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1280x720', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.13', + screen: '1280x800', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), +]; diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart new file mode 100644 index 0000000..3428fd8 --- /dev/null +++ b/lib/core/config/proxy_config.dart @@ -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 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 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 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); + } +} diff --git a/lib/core/config/spoof_data.dart b/lib/core/config/spoof_data.dart deleted file mode 100644 index f60c3ff..0000000 --- a/lib/core/config/spoof_data.dart +++ /dev/null @@ -1,31 +0,0 @@ -class SpoofData { - static const List deviceNames = [ - 'Samsung Galaxy S23', - 'Xiaomi 13 Pro', - 'Google Pixel 7', - 'OnePlus 11', - ]; - - static const List osVersions = ['12', '13', '14']; - - static const List resolutions = [ - '1080x2400', - '1440x3200', - '720x1600', - ]; - - static const List deviceIds = [ - 'a1b2c3d4e5f6', - 'f8e7d6c5b4a3', - '9876543210ab', - '1234567890cd', - ]; - - static const List 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'; -} diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 75cc983..edc40e5 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -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', diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 3455d03..7b6fb38 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -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 payload, {int seq = 0}) { final header = ByteData(headerSize); @@ -69,71 +96,73 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { } /// Распаковка пакета от сервера -Packet unpackPacket(Uint8List packet) { - // Для удобства расшифровки пакета переводим в ByteData - ByteData packetData = ByteData.view( - packet.buffer, - packet.offsetInBytes, - packet.lengthInBytes, - ); +Future 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-заголовка). diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3c0409a..5890f21 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -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? 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 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? 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 row) { + final profileOptionsStr = row['profile_options'] as String?; + List? 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 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 saveProfile(ProfileData profile) async { final db = await _instance; await db.insert( @@ -212,11 +290,7 @@ class AppDatabase { static Future 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 getSyncValue(int accountId, String key) async { @@ -276,22 +350,64 @@ class AppDatabase { }; } + static Future 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 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 close() async { await _db?.close(); _db = null; } - // Chats cache + // Chats cache static Future saveChats(List> 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>> 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>> loadChats(int accountId) async { final db = await _instance; return db.query( @@ -304,6 +420,73 @@ class AppDatabase { static Future 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 saveContacts(List> 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>> loadContacts(int accountId) async { + final db = await _instance; + return db.query( + 'contacts', + where: 'account_id = ?', + whereArgs: [accountId], + ); + } + + static Future saveMessages(List> 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>> 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 clearMessages(int accountId, int chatId) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); } } diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart new file mode 100644 index 0000000..b58cce2 --- /dev/null +++ b/lib/core/storage/spoofing_service.dart @@ -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?> 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, + }; + } +} diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 657ae14..75ab4bb 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -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 saveToken(String token, int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_tokenPrefix$accountId', token); + } - static Future saveToken(String token, int accountId) => - _storage.write(key: '$_tokenPrefix$accountId', value: token); + static Future readToken(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('$_tokenPrefix$accountId'); + } - static Future readToken(int accountId) => - _storage.read(key: '$_tokenPrefix$accountId'); + static Future deleteToken(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('$_tokenPrefix$accountId'); + } - static Future deleteToken(int accountId) => - _storage.delete(key: '$_tokenPrefix$accountId'); - - static Future setActiveAccount(int accountId) => - _storage.write(key: _activeAccountKey, value: accountId.toString()); + static Future setActiveAccount(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_activeAccountKey, accountId.toString()); + } static Future 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 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); } } } diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 16c4444..15c3a53 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -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? _subscription; SocketState _state = SocketState.disconnected; final _dataController = StreamController.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 disconnect() async { + _subscription?.cancel(); + _subscription = null; final socket = _socket; _socket = null; if (socket != null) { try { - socket.destroy(); + socket.close(); } catch (_) {} } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index efeec15..a7ff78e 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -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); } diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart new file mode 100644 index 0000000..aaac29b --- /dev/null +++ b/lib/core/transport/proxy_connector.dart @@ -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 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 _connectSocks5(String targetHost, int targetPort) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}'); + + final io = _RawSocketIO(proxySocket); + try { + // 1. Greeting + final useAuth = settings.hasCredentials; + if (useAuth) { + await io.write([0x05, 0x02, 0x00, 0x02]); + } else { + await io.write([0x05, 0x01, 0x00]); + } + + var response = await io.readExact(2); + if (response[0] != 0x05) { + throw SocketException( + 'SOCKS5: неверная версия протокола: ${response[0]}', + ); + } + + final method = response[1]; + if (method == 0xFF) { + throw SocketException( + 'SOCKS5: сервер отклонил все методы аутентификации', + ); + } + + // 2. Аутентификация (RFC 1929) + if (method == 0x02) { + if (!useAuth) { + throw SocketException('SOCKS5: прокси требует аутентификацию'); + } + final usernameBytes = utf8.encode(settings.username!); + final passwordBytes = utf8.encode(settings.password!); + final authPacket = BytesBuilder() + ..addByte(0x01) + ..addByte(usernameBytes.length) + ..add(usernameBytes) + ..addByte(passwordBytes.length) + ..add(passwordBytes); + await io.write(authPacket.toBytes()); + + final authResponse = await io.readExact(2); + if (authResponse[1] != 0x00) { + throw SocketException('SOCKS5: аутентификация не пройдена'); + } + logger.i('SOCKS5: аутентификация пройдена'); + } + + // 3. Connect request + final hostBytes = utf8.encode(targetHost); + final connectPacket = BytesBuilder() + ..addByte(0x05) // VER + ..addByte(0x01) // CMD: CONNECT + ..addByte(0x00) // RSV + ..addByte(0x03) // ATYP: domain + ..addByte(hostBytes.length) + ..add(hostBytes) + ..addByte((targetPort >> 8) & 0xFF) + ..addByte(targetPort & 0xFF); + await io.write(connectPacket.toBytes()); + + // 4. Reply + final reply = await io.readExact(4); + if (reply[0] != 0x05) { + throw SocketException('SOCKS5: неверная версия в ответе'); + } + if (reply[1] != 0x00) { + throw SocketException( + 'SOCKS5: ошибка подключения, код: ${reply[1]}', + ); + } + + // Пропускаем bind address + switch (reply[3]) { + case 0x01: + await io.readExact(4 + 2); + break; + case 0x03: + final lenBuf = await io.readExact(1); + await io.readExact(lenBuf[0] + 2); + break; + case 0x04: + await io.readExact(16 + 2); + break; + } + + logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен'); + + // Создаём локальную пару и проксируем данные + return _bridgeToFreshSocket(proxySocket, io); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } + } + + // ── HTTP CONNECT ──────────────────────────────────────────────────────── + + Future _connectHttpConnect( + String targetHost, + int targetPort, + ) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i( + 'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}', + ); + + final io = _RawSocketIO(proxySocket); + try { + final request = StringBuffer() + ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') + ..write('Host: $targetHost:$targetPort\r\n'); + + if (settings.hasCredentials) { + final credentials = base64Encode( + utf8.encode('${settings.username}:${settings.password}'), + ); + request.write('Proxy-Authorization: Basic $credentials\r\n'); + } + request.write('\r\n'); + + await io.write(utf8.encode(request.toString())); + + // Читаем HTTP-ответ до \r\n\r\n + final headerBytes = []; + while (true) { + final byte = await io.readExact(1); + headerBytes.add(byte[0]); + if (headerBytes.length >= 4 && + headerBytes[headerBytes.length - 4] == 0x0D && + headerBytes[headerBytes.length - 3] == 0x0A && + headerBytes[headerBytes.length - 2] == 0x0D && + headerBytes[headerBytes.length - 1] == 0x0A) { + break; + } + if (headerBytes.length > 8192) { + throw SocketException( + 'HTTP CONNECT: заголовок ответа слишком большой', + ); + } + } + + final responseStr = utf8.decode(headerBytes, allowMalformed: true); + final statusLine = responseStr.split('\r\n').first; + final parts = statusLine.split(' '); + 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 _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 _sub; + + final _readBuffer = []; + Completer? _readWaiter; + Completer? _writeWaiter; + bool _closed = false; + Object? _error; + + /// Коллбэк для данных в режиме моста. + void Function(Uint8List data)? onData; + + /// Коллбэк закрытия в режиме моста. + void Function()? onClosed; + + _RawSocketIO(this._socket) { + _sub = _socket.listen( + _onEvent, + onError: (Object err) { + _error = err; + _closed = true; + _readWaiter?.completeError(err); + _readWaiter = null; + _writeWaiter?.completeError(err); + _writeWaiter = null; + }, + ); + } + + void _onEvent(RawSocketEvent event) { + switch (event) { + case RawSocketEvent.read: + final data = _socket.read(); + if (data != null) { + if (onData != null) { + // Режим моста — пересылаем напрямую + onData!(data); + } else { + // Режим handshake — буферизуем + _readBuffer.addAll(data); + _readWaiter?.complete(); + _readWaiter = null; + } + } + break; + case RawSocketEvent.write: + _writeWaiter?.complete(); + _writeWaiter = null; + break; + case RawSocketEvent.readClosed: + case RawSocketEvent.closed: + _closed = true; + onClosed?.call(); + _readWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _readWaiter = null; + _writeWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _writeWaiter = null; + break; + } + } + + /// Читает ровно [count] байт. + Future readExact(int count) async { + while (_readBuffer.length < count) { + if (_error != null) throw _error!; + if (_closed) { + throw SocketException( + 'Соединение закрыто ' + '(ожидали $count байт, получили ${_readBuffer.length})', + ); + } + _readWaiter = Completer(); + await _readWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при чтении от прокси'), + ); + } + final result = Uint8List.fromList(_readBuffer.sublist(0, count)); + _readBuffer.removeRange(0, count); + return result; + } + + /// Записывает все байты. + Future write(List data) async { + var offset = 0; + while (offset < data.length) { + if (_error != null) throw _error!; + if (_closed) throw SocketException('Соединение закрыто при записи'); + final written = _socket.write(data, offset); + if (written > 0) { + offset += written; + } else { + _writeWaiter = Completer(); + await _writeWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при записи в прокси'), + ); + } + } + } + + /// Пересылает данные, оставшиеся в буфере после handshake, в мост. + void flushBuffered() { + if (_readBuffer.isNotEmpty && onData != null) { + onData!(Uint8List.fromList(_readBuffer)); + _readBuffer.clear(); + } + } + + void dispose() { + _sub.cancel(); + } +} diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index 25af729..a14476e 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -10,22 +10,22 @@ class PacketReceiver { static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта - /// Добавляет байты в буфер, возвращает все собранные пакеты. + /// Добавляет байты в буфер, возвращает поток собранных пакетов. /// Неполные данные остаются в буфере до следующего вызова. - List feed(Uint8List data) { + Stream 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 = []; - 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() { diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 31a5c2c..69e8c5d 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -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 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; } } diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 84ae57d..a57ef72 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -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 log(LogEvent event) { + final lines = []; + 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(); + } +} diff --git a/lib/frontend/debug/fps_overlay_layer.dart b/lib/frontend/debug/fps_overlay_layer.dart new file mode 100644 index 0000000..c9af7b0 --- /dev/null +++ b/lib/frontend/debug/fps_overlay_layer.dart @@ -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 createState() => _FpsOverlayLayerState(); +} + +class _FpsOverlayLayerState extends State { + static const int _maxSamples = 90; + static const int _minUiRefreshMs = 160; + static const double _initialWidthGuess = 96; + static const double _initialHeightGuess = 36; + + final List _frameMicros = []; + 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 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(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()], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index ec9eefd..3840751 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -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 createState() => _CodeConfirmationScreenState(); } -class _CodeConfirmationScreenState extends State { +class _CodeConfirmationScreenState extends State + with TickerProviderStateMixin { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); int _timerSeconds = 30; Timer? _timer; + Timer? _errorTimer; + + String? _errorMessage; + late AnimationController _shakeController; + late Animation _shakeAnimation; @override void initState() { @@ -33,11 +41,26 @@ class _CodeConfirmationScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); + + _shakeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _shakeAnimation = TweenSequence([ + 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 { }); } + 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 { 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 { ); } 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 { ), const SizedBox(height: 12), Text( - 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.', + l10n.codeConfirmationSmsSent, style: TextStyle( color: cs.outline, fontSize: 15, @@ -159,92 +184,156 @@ class _CodeConfirmationScreenState extends State { ), ), 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 animation) { - return ScaleTransition( - scale: animation, - child: FadeTransition(opacity: animation, child: child), - ); - }, - child: Text( - char, - key: ValueKey(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 animation, + ) { + return ScaleTransition( + scale: animation, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: Text( + char, + key: ValueKey( + 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 { 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 { ), child: Icon( Icons.arrow_forward, - color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant, + color: _codeController.text.length == 6 + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, ), ), ], diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 89b463b..c64d751 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -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 { 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 _checkTOS() async { final prefs = await SharedPreferences.getInstance(); if (mounted) { @@ -54,8 +78,10 @@ class _LoginScreenState extends State { final result = await Navigator.push( 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 { } } + 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( + 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 { 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 { child: ListView( controller: scrollController, children: [ - //У мя вопрос а тут точно номера статей верные??? Text( - 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' - '1. Статус и отношения\n' - '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' - '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' - '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' - '2. Условия использования\n' - '2.1. Используя Приложение «Komet», вы:\n' - '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' - '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' - '• Принимаете на себя все риски, связанные с использованием Приложения.\n' - '2.2. Строго запрещено:\n' - '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' - '• Осуществлять массовые рассылки (спам);\n' - '• Нарушать законодательство РФ и международное право;\n' - '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' - '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' - '2.4. Особенности технического взаимодействия:\n' - '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' - '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' - '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' - '3. Технические аспекты\n' - '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' - '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' - '3.3. Разработчики не несут ответственности за:\n' - '• Изменения в API оригинального сервиса;\n' - '• Блокировку аккаунтов пользователей;\n' - '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' - '4. Конфиденциальность\n' - '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' - '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' - '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' - '5. Ответственность и ограничения\n' - '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' - '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' - '6. Правовые основания\n' - '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' - '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' - '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' - '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' - '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' - '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' - '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' - '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' - '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' - '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' - '7. Заключительные положения\n' - '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' - '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' - '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' - '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' - '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' - '8. Функции безопасности и конфиденциальности\n' - '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' - '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' - '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' - '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' - '8.2. Данные функции:\n' - '• Направлены исключительно на защиту конфиденциальности пользователей;\n' - '• Не используются для обхода систем безопасности оригинального сервиса;\n' - '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' - '8.3. Разработчики не несут ответственности за:\n' - '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' - '• Изменения в работе сервиса при активации данных функций.\n' - '8.4. Функции экспорта и импорта сессии\n' - '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' - '• Обеспечения переносимости данных между устройствами пользователя\n' - '• Резервного копирования учетных данных\n' - '• Восстановления доступа при утере устройства\n' - '8.4.2. Особенности реализации:\n' - '• Экспорт сессии осуществляется без привязки к номеру телефона\n' - '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' - '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' - '8.4.3. Техническая реализация экспорта сессии:\n' - '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' - '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' - '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' - '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' - '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' - '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' - '8.4.4. Правовые основания:\n' - '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' - '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' - '• Принцип минимизации данных — сбор только необходимой для работы информации\n' - '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' - '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' - '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' - '8.4.5. Ограничения ответственности:\n' - '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' - '• Разработчики не имеют доступа к зашифрованным данным сессии\n' - '• Восстановление утерянных паролей невозможно в целях безопасности\n' - '• Ключи шифрования не хранятся в приложении и известны только пользователю', + termsOfServiceBody(termsLocale), style: TextStyle( color: cs.onSurfaceVariant, fontSize: 14, @@ -308,9 +321,20 @@ class _LoginScreenState extends State { ); } + 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 { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Это правильный номер?', + l10n.loginConfirmPhoneTitle, style: GoogleFonts.inter( color: cs.onSurfaceVariant, fontSize: 14, @@ -363,7 +387,7 @@ class _LoginScreenState extends State { 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 { 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 { 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( + 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( + 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 { 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 { ), ), 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 { 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 { ), ), 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 { 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 { 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 { 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 { 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 { @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 { ), ), IconButton( - onPressed: () {}, + onPressed: _showLanguagePicker, icon: Icon( Symbols.language, color: cs.onSurfaceVariant, @@ -613,7 +695,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 16), Text( - 'Войдите в Komet', + l10n.loginTitle, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 32, @@ -622,7 +704,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 8), Text( - 'Проверьте код страны и введите свой\nномер телефона.', + l10n.loginSubtitle, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurfaceVariant, @@ -639,11 +721,11 @@ class _LoginScreenState extends State { 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 { ), const SizedBox(height: 40), _buildInputField( - label: 'Номер телефона', + label: l10n.loginPhoneNumber, content: Row( children: [ Text( @@ -681,6 +763,7 @@ class _LoginScreenState extends State { 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 { 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 { ], ), ), - 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 { ), children: [ TextSpan( - text: - 'Продолжая, вы соглашаетесь с \n', + text: l10n.loginTermsIntro, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 14, @@ -759,8 +862,7 @@ class _LoginScreenState extends State { ), ), TextSpan( - text: - 'пользовательскими соглашениями', + text: l10n.loginTermsLink, style: GoogleFonts.inter( color: cs.primary, fontSize: 14, diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index faf6406..9a99a42 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -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 createState() => _Password2FAScreenState(); @@ -43,8 +40,7 @@ class _Password2FAScreenState extends State { if (!mounted) return; - // После успешной 2FA делаем login - final loginResult = await accountModule.login(); + await accountModule.login(); if (!mounted) return; @@ -60,9 +56,7 @@ class _Password2FAScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Неверный пароль: $e')), - ); + showCustomNotification(context, 'Неверный пароль: $e'); } } @@ -131,7 +125,9 @@ class _Password2FAScreenState extends State { ), suffixIcon: IconButton( icon: Icon( - _isPasswordVisible ? Icons.visibility_off : Icons.visibility, + _isPasswordVisible + ? Icons.visibility_off + : Icons.visibility, color: cs.onSurfaceVariant, ), onPressed: () { diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart new file mode 100644 index 0000000..8553ab1 --- /dev/null +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -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 createState() => _ProxySettingsSheetState(); +} + +class _ProxySettingsSheetState extends State { + 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 _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 _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 _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? 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, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index a72335e..bf4bd03 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -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 countries; - const SelectCountryScreen({super.key, required this.selectedCountry}); + SelectCountryScreen({ + super.key, + required this.selectedCountry, + List? countries, + }) : countries = countries ?? allCountries; @override State createState() => _SelectCountryScreenState(); @@ -15,7 +21,13 @@ class SelectCountryScreen extends StatefulWidget { class _SelectCountryScreenState extends State { bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); - List _filteredCountries = allCountries; + late List _filteredCountries; + + @override + void initState() { + super.initState(); + _filteredCountries = widget.countries; + } @override void dispose() { @@ -26,10 +38,10 @@ class _SelectCountryScreenState extends State { 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 { @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 { 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 { onChanged: _filterCountries, ) : Text( - 'Выберите страну', + l10n.selectCountryTitle, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 20, @@ -87,7 +101,7 @@ class _SelectCountryScreenState extends State { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); - _filteredCountries = allCountries; + _filteredCountries = widget.countries; } }); }, @@ -114,7 +128,7 @@ class _SelectCountryScreenState extends State { ), ), title: Text( - country.ru, + lang == 'ru' ? country.ru : country.en, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart new file mode 100644 index 0000000..534338a --- /dev/null +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -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 createState() => _ServerSettingsSheetState(); +} + +class _ServerSettingsSheetState extends State { + final TextEditingController _hostController = TextEditingController( + text: ServerConfig.defaultHost, + ); + final TextEditingController _portController = TextEditingController( + text: '${ServerConfig.defaultPort}', + ); + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final endpoint = await ServerConfig.loadEndpoint(); + if (!mounted) return; + setState(() { + _hostController.text = endpoint.host; + _portController.text = '${endpoint.port}'; + }); + } + + Future _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 _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? 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, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/auth/spoff_redacted_screen.dart b/lib/frontend/screens/auth/spoff_redacted_screen.dart deleted file mode 100644 index a553a59..0000000 --- a/lib/frontend/screens/auth/spoff_redacted_screen.dart +++ /dev/null @@ -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 createState() => _SpoffRedactedScreenState(); -} - -class _SpoffRedactedScreenState extends State { - 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 _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 _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, - ), - ), - ), - ], - ); - } -} diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 21bf27b..09edce7 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -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 createState() => _CallsTabState(); +} + +class _CallsTabState extends State { + List _calls = []; + bool _isLoading = true; + int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные' + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _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 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], + ); + }, + ), + ), + ], ), ), ); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 678ac56..986cbf2 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,14 +1,55 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/messages.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; import 'chat_screen.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; +import '../../../backend/api.dart'; +import '../../../backend/models/chat_folder.dart'; +import '../../../backend/modules/account.dart'; +import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/folders.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../main.dart' show accountModule, api, messagesModule; + +class _StoriesScrollPhysics extends BouncingScrollPhysics { + final bool Function() blockPositive; + final bool Function() allowPullOverscrollTop; + + const _StoriesScrollPhysics({ + required this.blockPositive, + required this.allowPullOverscrollTop, + super.parent, + }); + + @override + _StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _StoriesScrollPhysics( + blockPositive: blockPositive, + allowPullOverscrollTop: allowPullOverscrollTop, + parent: buildParent(ancestor), + ); + } + + @override + double applyBoundaryConditions(ScrollMetrics position, double value) { + if (blockPositive() && value > 0.0) { + return value - max(0.0, position.pixels); + } + if (!allowPullOverscrollTop() && + value < position.minScrollExtent && + position.pixels <= position.minScrollExtent) { + return value - position.minScrollExtent; + } + return super.applyBoundaryConditions(position, value); + } +} class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -18,16 +59,51 @@ class ChatListScreen extends StatefulWidget { } class _ChatListScreenState extends State - with SingleTickerProviderStateMixin { - String _selectedCategory = 'Все чаты'; - int _currentNavIndex = 0; - bool _isFabOpen = false; - late AnimationController _fabController; - final Set _selectedChats = {}; - final ScrollController _scrollController = ScrollController(); - double _pullRatio = 0.0; // 0.0 = folded (hidden row), 1.0 = fully expanded + with TickerProviderStateMixin { + String? _selectedFolderId; + List _folders = []; + + int _currentNavIndex = 0; + + double _navPageAnimStart = 0; + double _navPageAnimEnd = 0; + double _navDragDx = 0; + double _navDragBaseLeft = 0; + double _revealAnimBegin = 0.0; + double _closeAnimBegin = 0.0; + double _pullRatio = 0.0; + static const double _kStoriesPullTriggerPx = 16.0; + + bool _navDragging = false; + bool _isFabOpen = false; + bool _showCacheWarning = false; + bool _storiesAnimClosing = false; + bool _storiesDockedOpen = false; + bool _storiesOverscrollRevealArmed = true; + bool _shouldCollapseSearch = false; bool get _isSelectionMode => _selectedChats.isNotEmpty; + bool? _foldersListKnown; + + late AnimationController _navPageAnimController; + late AnimationController _fabController; + late PageController _folderPageController; + late AnimationController _storiesRevealController; + + final List _folderChatScrollControllers = []; + final List _folderChatScrollListenerFns = []; + final Set _selectedChats = {}; + + DateTime _storiesRevealLayoutSettleUntil = + DateTime.fromMillisecondsSinceEpoch(0); + ProfileData? _profile; + + List _chats = []; + + SessionState _sessionState = SessionState.disconnected; + + StreamSubscription? _stateSub; + StreamSubscription? _loginSub; void _toggleSelection(String chatId) { setState(() { @@ -36,15 +112,44 @@ class _ChatListScreenState extends State } else { _selectedChats.add(chatId); } + + _shouldCollapseSearch = _isSelectionMode; }); } void _clearSelection() { setState(() { _selectedChats.clear(); + _shouldCollapseSearch = false; }); } + bool _isInitialLoading = true; + DateTime _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0); + + bool _shouldBlockPositiveScroll() { + if (_pullRatio > 0 || + _storiesDockedOpen || + _storiesRevealController.isAnimating) { + return true; + } + if (DateTime.now().isBefore(_storiesLockdownUntil)) { + return true; + } + return false; + } + + bool _allowStoriesPullOverscrollTop() { + if (_storiesDockedOpen || + _storiesRevealController.isAnimating || + _pullRatio > 0) { + return true; + } + return _storiesOverscrollRevealArmed; + } + + late AnimationController _shimmerController; + @override void initState() { super.initState(); @@ -52,34 +157,532 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); - _scrollController.addListener(_onScroll); + _navPageAnimController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + )..addListener(() { + if (mounted) setState(() {}); + }); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + + _storiesRevealController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ) + ..addListener(_onStoriesRevealTick) + ..addStatusListener(_onStoriesRevealStatus); + + _folderPageController = PageController(); + _syncFolderChatScrollControllers(); + + _sessionState = api.state; + _stateSub = api.stateStream.listen((state) { + if (mounted) { + setState(() { + _sessionState = state; + if (state == SessionState.disconnected && _chats.isNotEmpty) { + _showCacheWarning = true; + } + if (state == SessionState.online) { + _showCacheWarning = false; + } + }); + if (state == SessionState.online) { + _reloadChatsAndFolders(); + } + } + }); + + _loginSub = accountModule.loginStatusStream.listen((status) { + if (status == LoginStatus.success) { + _reloadChatsAndFolders(); + } + }); + _reloadChatsAndFolders(); } - void _onScroll() { - if (_scrollController.hasClients) { - final double offset = _scrollController.offset; - if (offset < 0) { - final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); - if (newRatio != _pullRatio) { - setState(() { - _pullRatio = newRatio; - }); - } - } else if (_pullRatio > 0) { + Future _reloadChatsAndFolders() async { + final p = await AppDatabase.loadActiveProfile(); + if (p == null) { + _syncFolderChatScrollControllersForCount(1); + if (mounted) { setState(() { - _pullRatio = 0.0; + _folders = []; + _selectedFolderId = null; + _foldersListKnown = null; + _isInitialLoading = false; + }); + } + return; + } + + try { + final chats = await ChatsModule.getChats(p.id); + var folders = await FoldersModule.loadFolders(p.id); + final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); + + final allChatsFolder = ChatFolder( + id: 'all.chat.folder', + title: 'Все чаты', + filters: [], + hideEmpty: false, + widgets: [], + ); + + if (!folders.any((f) => FoldersModule.isAllChatsFolder(f))) { + folders = [allChatsFolder, ...folders]; + } + + final pageCount = folders.isEmpty ? 1 : folders.length; + _syncFolderChatScrollControllersForCount(pageCount); + if (mounted) { + setState(() { + _profile = p; + _chats = chats; + _folders = folders; + _foldersListKnown = foldersKnown; + if (_selectedFolderId != null && + !_folders.any((f) => f.id == _selectedFolderId)) { + _selectedFolderId = null; + } + if (_folders.isNotEmpty) { + final preferred = FoldersModule.preferredInitialFolderId(_folders); + if (_selectedFolderId == null || + !_folders.any((f) => f.id == _selectedFolderId)) { + _selectedFolderId = preferred; + } + } else { + _selectedFolderId = null; + } + _isInitialLoading = false; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _jumpFolderPageToSelection(); + }); + } + } catch (_) { + _syncFolderChatScrollControllersForCount(1); + if (mounted) { + setState(() { + _folders = []; + _selectedFolderId = null; + _foldersListKnown = null; + _isInitialLoading = false; + }); + } + } finally { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _jumpFolderPageToSelection(); }); } } } + bool get _showFoldersShimmer { + if (_profile == null) return false; + if (_foldersListKnown != false) return false; + return _sessionState != SessionState.disconnected; + } + + int get _folderPageCount => _folders.isEmpty ? 1 : _folders.length; + + int get _selectedFolderIndex { + if (_folders.isEmpty) return 0; + final i = _folders.indexWhere((f) => f.id == _selectedFolderId); + if (i >= 0) return i; + return 0; + } + + int _folderIndexForId(String? id) { + if (_folders.isEmpty) return 0; + if (id == null) return 0; + final i = _folders.indexWhere((f) => f.id == id); + if (i >= 0) return i; + final pref = FoldersModule.preferredInitialFolderId(_folders); + if (pref != null) { + final j = _folders.indexWhere((f) => f.id == pref); + if (j >= 0) return j; + } + return 0; + } + + List _chatsForPageIndex(int pageIndex) { + if (_folders.isEmpty) return _chats; + if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; + final folder = _folders[pageIndex]; + if (FoldersModule.isAllChatsFolder(folder)) return _chats; + return _chats + .where((c) => FoldersModule.chatMatchesFolder(c, folder)) + .toList(); + } + + void _syncFolderChatScrollControllers() { + _syncFolderChatScrollControllersForCount(_folderPageCount); + } + + void _syncFolderChatScrollControllersForCount(int n) { + while (_folderChatScrollControllers.length < n) { + final i = _folderChatScrollControllers.length; + void fn() => _onFolderChatScrollAt(i); + final c = ScrollController(); + c.addListener(fn); + _folderChatScrollControllers.add(c); + _folderChatScrollListenerFns.add(fn); + } + while (_folderChatScrollControllers.length > n) { + final c = _folderChatScrollControllers.removeLast(); + final fn = _folderChatScrollListenerFns.removeLast(); + c.removeListener(fn); + c.dispose(); + } + } + + bool _isChatScrollControllerActive(int index) { + if (_folderPageCount <= 1) return index == 0; + if (!_folderPageController.hasClients) { + return index == _selectedFolderIndex; + } + final p = _folderPageController.page; + if (p == null) return index == _selectedFolderIndex; + final r = p.round().clamp(0, _folderPageCount - 1); + return r == index; + } + + void _onFolderChatScrollAt(int index) { + if (!_isChatScrollControllerActive(index)) return; + if (index < 0 || index >= _folderChatScrollControllers.length) return; + final c = _folderChatScrollControllers[index]; + _applyChatScrollOffset(c); + } + + void _applyChatScrollOffset(ScrollController c) { + if (!c.hasClients) return; + final double offset = c.offset; + if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { + setState(() { + _shouldCollapseSearch = true; + }); + } + + if (offset < 0) { + if (!_allowStoriesPullOverscrollTop()) { + return; + } + final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); + if (_storiesRevealController.isAnimating) { + return; + } + if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) { + _startStoriesAutoReveal(dragRatio); + } else if (!_storiesDockedOpen) { + if (dragRatio != _pullRatio) { + setState(() { + _pullRatio = dragRatio; + }); + } + } + } else { + if (_storiesDockedOpen && + offset > 12 && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } + if (_storiesDockedOpen || _storiesRevealController.isAnimating) { + return; + } + final disarm = offset > 3 && _storiesOverscrollRevealArmed; + final clearPull = _pullRatio > 0; + if (disarm || clearPull) { + setState(() { + if (disarm) { + _storiesOverscrollRevealArmed = false; + } + if (clearPull) { + _pullRatio = 0.0; + } + }); + } + } + } + + ScrollController? _activeChatScrollController() { + if (_folderChatScrollControllers.isEmpty) return null; + if (!_folderPageController.hasClients) { + return _folderChatScrollControllers.first; + } + final p = _folderPageController.page; + final i = (p != null ? p.round() : _selectedFolderIndex).clamp( + 0, + _folderChatScrollControllers.length - 1, + ); + return _folderChatScrollControllers[i]; + } + + void _jumpFolderPageToSelection() { + if (!_folderPageController.hasClients) return; + final target = _folderIndexForId(_selectedFolderId); + final current = _folderPageController.page?.round(); + if (current != target) { + _folderPageController.jumpToPage(target); + } + } + + String _formatTime(int? timestamp) { + if (timestamp == null || timestamp == 0) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + + Widget _buildChatShimmer() { + final cs = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(7), + ), + ), + Container( + width: double.infinity, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _onStoriesRevealTick() { + if (!mounted) return; + final t = Curves.easeOutCubic.transform(_storiesRevealController.value); + setState(() { + if (_storiesAnimClosing) { + _pullRatio = _closeAnimBegin * (1.0 - t); + } else { + _pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t; + } + }); + } + + void _onStoriesRevealStatus(AnimationStatus status) { + if (!mounted) return; + if (status == AnimationStatus.completed) { + setState(() { + if (_storiesAnimClosing) { + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; + } else { + _pullRatio = 1.0; + _storiesDockedOpen = true; + _storiesRevealLayoutSettleUntil = DateTime.now().add( + const Duration(milliseconds: 520), + ); + } + }); + } + } + + void _startStoriesAutoReveal(double suggestedFrom) { + if (_storiesRevealController.isAnimating && !_storiesAnimClosing) return; + if (_storiesDockedOpen) return; + _storiesRevealController.stop(); + _storiesAnimClosing = false; + final from = max(_pullRatio, suggestedFrom.clamp(0.0, 1.0)); + if (from >= 1.0) { + setState(() { + _pullRatio = 1.0; + _storiesDockedOpen = true; + }); + _storiesRevealLayoutSettleUntil = DateTime.now().add( + const Duration(milliseconds: 520), + ); + return; + } + _revealAnimBegin = from; + _storiesRevealController.duration = Duration( + milliseconds: (260 + 240 * (1.0 - from)).round(), + ); + _storiesRevealController.reset(); + _storiesRevealController.forward(from: 0); + } + + void _startStoriesAutoClose() { + if (_pullRatio <= 0 && + !_storiesDockedOpen && + !_storiesRevealController.isAnimating) { + return; + } + if (_storiesAnimClosing && _storiesRevealController.isAnimating) return; + _storiesLockdownUntil = DateTime.now().add( + const Duration(milliseconds: 800), + ); + _storiesRevealController.stop(); + _storiesAnimClosing = true; + final from = _pullRatio.clamp(0.0, 1.0); + if (from <= 0) { + setState(() { + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; + }); + return; + } + _closeAnimBegin = from; + _storiesRevealController.duration = Duration( + milliseconds: (260 + 240 * from).round(), + ); + _storiesRevealController.reset(); + _storiesRevealController.forward(from: 0); + } + + bool _handleStoriesScrollNotification(ScrollNotification n) { + if (_currentNavIndex != 0) return false; + + if (n is ScrollEndNotification) { + if (n.metrics.pixels <= 0.5) { + setState(() { + _storiesOverscrollRevealArmed = true; + }); + } + return false; + } + + if (n is OverscrollNotification && n.overscroll > 0) { + if ((_storiesDockedOpen || + _storiesRevealController.isAnimating || + _pullRatio > 0) && + !_storiesAnimClosing && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } + return false; + } + + if (n is! ScrollUpdateNotification) return false; + if (!_storiesDockedOpen || _storiesRevealController.isAnimating) { + return false; + } + if (!DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + return false; + } + if (n.dragDetails == null) { + return false; + } + final m = n.metrics; + if (m.axis != Axis.vertical) return false; + if (m.pixels > m.minScrollExtent + 1.0) return false; + final d = n.scrollDelta; + if (d == null || d <= 0) return false; + _startStoriesAutoClose(); + return false; + } + @override void dispose() { + _loginSub?.cancel(); + _stateSub?.cancel(); _fabController.dispose(); - _scrollController.dispose(); + _navPageAnimController.dispose(); + _storiesRevealController + ..removeListener(_onStoriesRevealTick) + ..removeStatusListener(_onStoriesRevealStatus) + ..dispose(); + _folderPageController.dispose(); + while (_folderChatScrollControllers.isNotEmpty) { + final c = _folderChatScrollControllers.removeLast(); + final fn = _folderChatScrollListenerFns.removeLast(); + c.removeListener(fn); + c.dispose(); + } super.dispose(); } + double _effectivePageNavRowT({ + required double inactiveWidth, + required double Function(int index) bubbleLeftForIndex, + }) { + if (_navDragging) { + final left = (_navDragBaseLeft + _navDragDx).clamp( + bubbleLeftForIndex(0), + bubbleLeftForIndex(3), + ); + return ((left - 4) / inactiveWidth).clamp(0.0, 3.0); + } + if (_navPageAnimController.isAnimating) { + final t = Curves.easeOutCubic.transform(_navPageAnimController.value); + return ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } + return _currentNavIndex.toDouble(); + } + + void _onNavTabSelected(int index) { + if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { + return; + } + double fromT; + if (_navPageAnimController.isAnimating) { + final t = Curves.easeOutCubic.transform(_navPageAnimController.value); + fromT = ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } else { + fromT = _currentNavIndex.toDouble(); + } + _navPageAnimStart = fromT; + _navPageAnimEnd = index.toDouble(); + setState(() => _currentNavIndex = index); + _navPageAnimController.forward(from: 0); + } + void _toggleFab() { setState(() { _isFabOpen = !_isFabOpen; @@ -91,222 +694,205 @@ class _ChatListScreenState extends State }); } - @override - Widget build(BuildContext context) { + Widget _buildPinnedChatsHeader(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: cs.surface, - body: SafeArea( - bottom: false, - child: Stack( - children: [ - IndexedStack( - index: _currentNavIndex, - children: [ - Listener( - onPointerSignal: (pointerSignal) { - if (pointerSignal is PointerScrollEvent) { - if (_scrollController.hasClients && - _scrollController.offset <= 0) { - if (pointerSignal.scrollDelta.dy < 0) { - // Scrolled UP (pulling down) - setState(() { - _pullRatio = (_pullRatio + 0.2).clamp(0.0, 1.0); - }); - } else if (pointerSignal.scrollDelta.dy > 0 && - _pullRatio > 0) { - // Scrolled DOWN (folding up) - setState(() { - _pullRatio = (_pullRatio - 0.2).clamp(0.0, 1.0); - }); - } - } - } - }, - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: _isSelectionMode - ? 0 - : (132 + (96 * _pullRatio)), - color: Colors.transparent, - clipBehavior: Clip.hardEdge, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - transform: Matrix4.translationValues( - 0, - _isSelectionMode ? -100 : 0, - 0, - ), - child: Column( + return ColoredBox( + color: cs.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRect( + clipBehavior: Clip.hardEdge, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _shouldCollapseSearch + ? const SizedBox(width: double.infinity, height: 52) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (_pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only(right: 8), + child: Stack( + children: [ + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), + ), + ), + Text( + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + ), + ], + ), + ), + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 12, - 20, - 4, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (_pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, - ), - child: Stack( - children: [ - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], - ), - ), - ), - Text( - 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 16, - ), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( - 2, - 'Кнопка 2', - Symbols.notifications, - ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, - ), - ], - ), - ], - ), + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, ), - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, ), - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 4, - 20, - 12, + ], + ), + ), + ), + if (_showCacheWarning) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.error.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon( + Symbols.cloud_off, + size: 18, + color: cs.error, ), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Ошибка соединения, сейчас вы смотрите КЕШ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), - padding: const EdgeInsets.symmetric( - horizontal: 16, + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, ), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - ), - ), - ], + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, ), ), ), @@ -314,318 +900,644 @@ class _ChatListScreenState extends State ), ), ), - ), - SliverPadding( - padding: EdgeInsets.only( - top: _isSelectionMode ? 64 : 0, - ), - sliver: SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - height: 48, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, + ], + ), + ), + ), + if (_folders.length > 1) + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + color: cs.surface, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: _showFoldersShimmer + ? _buildFolderStripShimmer(cs) + : LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth - 40; + final folderCount = _folders.length; + final minWidthPerFolder = 80.0; + final totalMinWidth = + folderCount * minWidthPerFolder + + (folderCount - 1) * 8; + final needsScroll = totalMinWidth > availableWidth; + + if (needsScroll) { + return ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + physics: const BouncingScrollPhysics(), + children: [ + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - physics: const BouncingScrollPhysics(), + ], + ], + ); + } else { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + child: Row( children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded( + child: _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, + ), + ), + ], ], ), - ), - ), - ), + ); + } + }, ), - SliverList( - delegate: SliverChildListDelegate([ - _buildChatItem( - 'stas', - 'Станислав', - 'Хорошо', - '10:07', - 'https://i.pravatar.cc/150?u=stas', - isOnline: true, - isRead: true, - ), - _buildChatItem( - 'ilya', - 'Илья', - 'печатает...', - '10:07', - 'https://i.pravatar.cc/150?u=ilya', - isOnline: true, - isTyping: true, - unreadCount: 1, - ), - _buildChatItem( - 'veronika', - 'Вероника', - 'Спасибо', - '09:56', - 'https://i.pravatar.cc/150?u=veronika', - isRead: true, - ), - _buildChatItem( - 'komet', - 'Komet Client', - 'Кстати. Смотрите, какую шту...', - '09:56', - 'https://i.pravatar.cc/150?u=komet', - unreadCount: 5, - isMuted: true, - ), - _buildChatItem( - 'podezd', - '4-й подъезд', - 'Людмила: Сколько?', - '09:34', - 'https://i.pravatar.cc/150?u=podezd', - unreadCount: 78, - isMuted: true, - ), - ]), - ), - ], - ), // CustomScrollView - ), // Listener - const CallsTab(), - const ContactsTab(), - const SettingsTab(), - ], + ), ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - left: 8, - right: 8, - bottom: _isSelectionMode ? -100 : 24.0, - child: RepaintBoundary( - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - double totalWidth = constraints.maxWidth; + ], + ), + ); + } - double totalWeight = 5.2; - double unitWidth = totalWidth / totalWeight; - double activeWidth = unitWidth * 2.2; - double inactiveWidth = unitWidth * 1.0; - - double leftOffset = 0; - for (int i = 0; i < _currentNavIndex; i++) { - leftOffset += inactiveWidth; - } - - return Stack( - children: [ - AnimatedPositioned( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: leftOffset + 4, - top: 8, - bottom: 8, - width: activeWidth - 8, - child: Container( - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(26), - ), - ), - ), - Row( - children: List.generate(4, (index) { - IconData icon; - String label; - switch (index) { - case 0: - icon = Symbols.chat_bubble; - label = 'Чаты'; - break; - case 1: - icon = Symbols.call; - label = 'Звонки'; - break; - case 2: - icon = Symbols.person_pin; - label = 'Контакты'; - break; - default: - icon = Symbols.settings; - label = 'Настройки'; - } - - bool isSelected = _currentNavIndex == index; - return AnimatedContainer( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - width: isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5), - child: ClipRRect( - borderRadius: BorderRadius.circular(26), - child: _buildNavItem(index, icon, label), - ), - ); - }), - ), - ], - ); - }, + Widget _buildFolderChatPage(int pageIndex) { + final chats = _chatsForPageIndex(pageIndex); + final sc = _folderChatScrollControllers[pageIndex]; + final cs = Theme.of(context).colorScheme; + return NotificationListener( + onNotification: (ScrollNotification n) { + if (_currentNavIndex != 0) return false; + if (!_folderPageController.hasClients) { + if (pageIndex != _selectedFolderIndex) return false; + } else { + final p = _folderPageController.page; + if (p == null) { + if (pageIndex != _selectedFolderIndex) return false; + } else { + final r = p.round().clamp(0, _folderPageCount - 1); + if (r != pageIndex) return false; + } + } + return _handleStoriesScrollNotification(n); + }, + child: CustomScrollView( + controller: sc, + physics: _StoriesScrollPhysics( + blockPositive: _shouldBlockPositiveScroll, + allowPullOverscrollTop: _allowStoriesPullOverscrollTop, + parent: const AlwaysScrollableScrollPhysics(), + ), + slivers: [ + if (chats.isEmpty && !_isInitialLoading) + SliverFillRemaining( + child: Center( + child: Text( + 'Кажется, тут пусто...', + style: TextStyle( + color: cs.onSurface.withOpacity(0.6), + fontSize: 16, ), ), ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = chats[index]; + + if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { + final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(secondId); + final name = ContactCache.get(secondId); + final avatar = ContactCache.getAvatar(secondId); + + return _buildChatItem( + chat.id.toString(), + name ?? "Пользователь", + chat.lastMsgText?.replaceAll('\n', ' ') ?? '', + _formatTime(chat.lastMsgTime), + avatar ?? "", + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } else { + if (chat.lastMsgSenderId != null ) { + final ss = messagesModule.searchContactById(chat.lastMsgSenderId!); + } + + final name = chat.lastMsgSenderId != null + ? ContactCache.get(chat.lastMsgSenderId!) + : null; + + final avatar = chat.lastMsgSenderId != null + ? ContactCache.getAvatar(chat.lastMsgSenderId!) + : null; + + String fullMsg = ""; + + if (name?.isNotEmpty == true && chat.id != 0) { + fullMsg += "$name: "; + } + + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + } + + return _buildChatItem( + chat.id.toString(), + chat.id == 0 ? "Избранное" : chat.title ?? "Чат", + fullMsg, + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } + }, childCount: _isInitialLoading ? 10 : chats.length), ), - AnimatedBuilder( - animation: _fabController, - builder: (context, child) { - final double val = Curves.easeOutCubic.transform( - _fabController.value, - ); - return Stack( - clipBehavior: Clip.none, - children: [ - if (_fabController.value > 0) - Positioned.fill( - child: GestureDetector( - onTap: _toggleFab, - behavior: HitTestBehavior.opaque, - child: Container( - color: Colors.black.withValues(alpha: val * 0.2), - ), - ), - ), - if (!_isSelectionMode) ...[ - if (_fabController.value > 0) - Positioned( - right: 20, - bottom: 110 + 74, - child: RepaintBoundary( - child: Transform.scale( - scale: val, - alignment: Alignment.bottomRight, - child: Opacity( - opacity: val > 0.5 ? (val - 0.5) * 2 : 0, - child: _buildFabMenu(), - ), - ), - ), - ), - Positioned( - right: 20, - bottom: 110, - child: FloatingActionButton( - onPressed: _toggleFab, - backgroundColor: cs.primaryContainer, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Transform.rotate( - angle: val * (pi / 4), - child: Icon( - Symbols.add, - color: cs.onPrimaryContainer, - size: 28, - weight: 400, - ), - ), - ), - ), - ], - ], - ); + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + ), + ), + ], + ), + ); + } + + Widget _buildChatsTabBody() { + return Listener( + onPointerDown: (_) { + _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0); + }, + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + if (_shouldBlockPositiveScroll() && + pointerSignal.scrollDelta.dy > 0) { + _storiesLockdownUntil = DateTime.now().add( + const Duration(milliseconds: 300), + ); + } + final ac = _activeChatScrollController(); + if (ac != null && ac.hasClients && ac.offset <= 0) { + if (pointerSignal.scrollDelta.dy < 0) { + if (_allowStoriesPullOverscrollTop()) { + _startStoriesAutoReveal(max(_pullRatio, 0.18)); + } + } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { + _startStoriesAutoClose(); + } + } + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: PageView.builder( + controller: _folderPageController, + physics: _folderPageCount <= 1 + ? const NeverScrollableScrollPhysics() + : const BouncingScrollPhysics(), + onPageChanged: (i) { + if (_folders.isEmpty) return; + if (i < 0 || i >= _folders.length) return; + setState(() { + _selectedFolderId = _folders[i].id; + }); + }, + itemCount: _folderPageCount, + itemBuilder: (context, pageIndex) { + return _buildFolderChatPage(pageIndex); }, ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - top: _isSelectionMode ? 0 : -80, - left: 0, - right: 0, - child: Container( - height: 64, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: cs.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 2), + ), + ], + ), + ); + } + + Widget _buildDockedBottomNav( + ColorScheme cs, + double navInnerW, + double bottomInset, + ) { + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final activeWidth = unitWidth * 2.2; + final inactiveWidth = unitWidth * 1.0; + + double bubbleLeftForIndex(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final leftOffset = bubbleLeftForIndex(_currentNavIndex); + final bubbleW = activeWidth - 8; + final minBubbleLeft = bubbleLeftForIndex(0); + final maxBubbleLeft = bubbleLeftForIndex(3); + + final bubbleLeft = _navDragging + ? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft) + : leftOffset; + + final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + + double navInterpolatedWidth(int tabIndex, double rowT) { + final rt = rowT.clamp(0.0, 3.0); + final i0 = rt.floor().clamp(0, 3); + final i1 = rt.ceil().clamp(0, 3); + final frac = i0 == i1 ? 0.0 : (rt - i0); + double at(int sel, int tab) => + (tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5); + return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac; + } + + int indexForBubbleLeft(double left) { + final cx = left + bubbleW / 2; + var best = 0; + var bestD = double.infinity; + for (var i = 0; i < 4; i++) { + final c = bubbleLeftForIndex(i) + bubbleW / 2; + final d = (c - cx).abs(); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + } + + return AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: 8, + right: 8, + bottom: _isSelectionMode ? -100 : bottomInset + 10.0, + child: RepaintBoundary( + child: Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + if (_isSelectionMode) return; + _navPageAnimController.stop(); + _navPageAnimController.value = 1.0; + setState(() { + _navDragging = true; + _navDragDx = 0; + _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); + }); + }, + onHorizontalDragUpdate: (details) { + if (!_navDragging) return; + setState(() { + _navDragDx += details.delta.dx; + }); + }, + onHorizontalDragEnd: (_) { + if (!_navDragging) return; + final left = (_navDragBaseLeft + _navDragDx).clamp( + minBubbleLeft, + maxBubbleLeft, + ); + final next = indexForBubbleLeft(left); + setState(() { + _currentNavIndex = next; + _navDragging = false; + _navDragDx = 0; + }); + }, + onHorizontalDragCancel: () { + if (!_navDragging) return; + setState(() { + _navDragging = false; + _navDragDx = 0; + }); + }, + child: Stack( + children: [ + AnimatedPositioned( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + left: bubbleLeft, + top: 8, + bottom: 8, + width: bubbleW, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(26), ), - ], + ), ), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, + Row( + children: List.generate(4, (index) { + IconData icon; + String label; + switch (index) { + case 0: + icon = Symbols.chat_bubble; + label = 'Чаты'; + break; + case 1: + icon = Symbols.call; + label = 'Звонки'; + break; + case 2: + icon = Symbols.person_pin; + label = 'Контакты'; + break; + default: + icon = Symbols.settings; + label = 'Настройки'; + } + + final isSelected = _currentNavIndex == index; + final visualSel = navRowT.round().clamp(0, 3); + return AnimatedContainer( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + width: _navDragging + ? navInterpolatedWidth(index, navRowT) + : (isSelected + ? (activeWidth - 0.5) + : (inactiveWidth - 0.5)), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _buildNavItem( + index, + icon, + label, + selectedOverride: _navDragging + ? (index == visualSel) + : null, + instant: _navDragging, + ), + ), + ); + }), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final pageW = constraints.maxWidth; + final pageH = constraints.maxHeight; + final navInnerW = pageW - 20; + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final inactiveWidth = unitWidth * 1.0; + double bubbleLeftForPageT(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + + final showChatsFab = + !_isSelectionMode && + (_navDragging || _navPageAnimController.isAnimating + ? pageDisplayT < 1.0 + : _currentNavIndex == 0); + + return Stack( + children: [ + ClipRect( + child: SizedBox( + width: pageW, + height: pageH, + child: OverflowBox( + alignment: Alignment.topLeft, + maxWidth: pageW * 4, + maxHeight: pageH, + child: SizedBox( + width: pageW * 4, + height: pageH, + child: Transform.translate( + offset: Offset(-pageDisplayT * pageW, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: _buildChatsTabBody(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const CallsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const ContactsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const SettingsTab(), + ), + ), + ], + ), + ), ), ), - const Spacer(), - IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.volume_off, color: cs.onSurface), - onPressed: () {}, - ), - ], + ), ), - ), - ), - ], + _buildDockedBottomNav(cs, navInnerW, bottomInset), + ListenableBuilder( + listenable: _fabController, + builder: (context, child) { + final double val = Curves.easeOutCubic.transform( + _fabController.value, + ); + return Stack( + clipBehavior: Clip.none, + children: [ + if (_fabController.value > 0) + Positioned.fill( + child: GestureDetector( + onTap: _toggleFab, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.black.withValues( + alpha: val * 0.2, + ), + ), + ), + ), + if (showChatsFab) ...[ + if (_fabController.value > 0) + Positioned( + right: 20, + bottom: bottomInset + 90 + 74, + child: RepaintBoundary( + child: Transform.scale( + scale: val, + alignment: Alignment.bottomRight, + child: Opacity( + opacity: val > 0.5 ? (val - 0.5) * 2 : 0, + child: _buildFabMenu(), + ), + ), + ), + ), + Positioned( + right: 20, + bottom: bottomInset + 90, + child: FloatingActionButton( + onPressed: _toggleFab, + backgroundColor: cs.primaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Transform.rotate( + angle: val * (pi / 4), + child: Icon( + Symbols.add, + color: cs.onPrimaryContainer, + size: 28, + weight: 400, + ), + ), + ), + ), + ], + ], + ); + }, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + top: _isSelectionMode ? 0 : -80, + left: 0, + right: 0, + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: cs.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.volume_off, color: cs.onSurface), + onPressed: () {}, + ), + ], + ), + ), + ), + ], + ); + }, ), ), ); @@ -635,41 +1547,109 @@ class _ChatListScreenState extends State final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(right: 16), - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(2.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasUpdate - ? Border.all(color: cs.primary, width: 2) - : Border.all(color: cs.outlineVariant), - ), - child: CircleAvatar( - radius: 26, - backgroundImage: NetworkImage(imageUrl), - ), + child: SizedBox( + width: 68, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(2.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: hasUpdate + ? Border.all(color: cs.primary, width: 2) + : Border.all(color: cs.outlineVariant), + ), + child: CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(imageUrl), + ), + ), + const SizedBox(height: 6), + Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], ), - const SizedBox(height: 6), - Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ], + ), ), ); } - Widget _buildFolderChip(String title) { + String _folderChipLabel(ChatFolder f) { + final e = f.emoji; + if (e != null && e.isNotEmpty) return '$e ${f.title}'; + return f.title; + } + + Widget _buildFolderStripShimmer(ColorScheme cs) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + physics: const BouncingScrollPhysics(), + children: [ + _folderShimmerPill(cs, 88), + const SizedBox(width: 8), + _folderShimmerPill(cs, 72), + const SizedBox(width: 8), + _folderShimmerPill(cs, 96), + const SizedBox(width: 8), + _folderShimmerPill(cs, 64), + const SizedBox(width: 8), + _folderShimmerPill(cs, 80), + ], + ), + ); + }, + ); + } + + Widget _folderShimmerPill(ColorScheme cs, double width) { + return Container( + width: width, + height: 32, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + ); + } + + Widget _buildFolderChip(String title, {required String folderId}) { final cs = Theme.of(context).colorScheme; - bool isSelected = _selectedCategory == title; + final isSelected = _selectedFolderId == folderId; return GestureDetector( - onTap: () => setState(() => _selectedCategory = title), + onTap: () { + final i = _folders.indexWhere((f) => f.id == folderId); + if (i < 0) return; + setState(() => _selectedFolderId = folderId); + if (_folderPageController.hasClients) { + final cur = _folderPageController.page?.round(); + if (cur != i) { + _folderPageController.animateToPage( + i, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } + } + }, child: Container( + alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, @@ -677,6 +1657,7 @@ class _ChatListScreenState extends State ), child: Text( title, + textAlign: TextAlign.center, style: TextStyle( color: isSelected ? cs.onPrimaryContainer : cs.primary, fontSize: 14, @@ -710,7 +1691,11 @@ class _ChatListScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(name: name, imageUrl: imageUrl), + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + ), ), ); } @@ -718,131 +1703,190 @@ class _ChatListScreenState extends State onLongPress: () => _toggleSelection(id), child: AnimatedContainer( duration: const Duration(milliseconds: 200), - color: isSelected ? cs.primary.withOpacity(0.08) : Colors.transparent, - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 2, - ), - leading: Stack( + color: isSelected + ? cs.primary.withValues(alpha: 0.08) + : Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)), - if (isSelected) - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - child: Icon( - Symbols.check, - color: cs.onPrimary, - size: 14, - weight: 600, - ), + Stack( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: cs.surfaceContainerHighest, + backgroundImage: imageUrl.isNotEmpty + ? NetworkImage(imageUrl) + : null, + child: imageUrl.isEmpty + ? Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 20, + ), + ) + : null, ), - ) - else if (isOnline) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), + if (isSelected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 14, + ), + ), + ) + else if (isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + ), ), - ), - ), - ], - ), - title: Row( - children: [ + ], + ), + const SizedBox(width: 12), Expanded( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w600, + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isMuted) ...[ + const SizedBox(width: 4), + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], + const SizedBox(width: 8), + Text( + time, + style: TextStyle(color: cs.outline, fontSize: 12), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + color: isTyping ? cs.primary : cs.outline, + fontSize: 14, + fontWeight: isTyping + ? FontWeight.w500 + : FontWeight.w400, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (unreadCount > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isMuted + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unreadCount.toString(), + style: TextStyle( + color: isMuted ? cs.outline : cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ) + else if (isRead) + Icon( + Symbols.done_all, + color: cs.primary, + size: 16, + weight: 400, + ), + ], + ), + ), + ], ), ), ), - if (isMuted) - Icon( - Symbols.notifications_off, - color: cs.outlineVariant, - size: 14, - weight: 400, - ), - const SizedBox(width: 8), - Text(time, style: TextStyle(color: cs.outline, fontSize: 12)), ], ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - children: [ - Expanded( - child: Text( - message, - style: TextStyle( - color: isTyping ? cs.primary : cs.outline, - fontSize: 14, - fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: isMuted - ? cs.surfaceContainerHighest - : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - unreadCount.toString(), - style: TextStyle( - color: isMuted ? cs.outline : cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ) - else if (isRead) - Icon( - Symbols.done_all, - color: cs.primary, - size: 16, - weight: 400, - ), - ], - ), - ), ), ), ); } - Widget _buildNavItem(int index, IconData icon, String label) { + Widget _buildNavItem( + int index, + IconData icon, + String label, { + bool? selectedOverride, + bool instant = false, + }) { final cs = Theme.of(context).colorScheme; - bool isSelected = _currentNavIndex == index; + final bool isSelected = selectedOverride ?? (_currentNavIndex == index); + final Duration animDur = instant + ? Duration.zero + : const Duration(milliseconds: 350); + final Duration opacityDur = instant + ? Duration.zero + : const Duration(milliseconds: 200); return GestureDetector( - onTap: () => setState(() => _currentNavIndex = index), + onTap: () => _onNavTabSelected(index), behavior: HitTestBehavior.opaque, child: Center( child: FittedBox( @@ -855,15 +1899,13 @@ class _ChatListScreenState extends State icon, color: isSelected ? cs.onPrimary : cs.onSurface, size: 20, - weight: 400, - fill: isSelected ? 1.0 : 0.0, ), AnimatedContainer( - duration: const Duration(milliseconds: 350), + duration: animDur, curve: Curves.easeOutCubic, width: isSelected ? null : 0, child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: opacityDur, opacity: isSelected ? 1.0 : 0.0, child: Row( mainAxisSize: MainAxisSize.min, @@ -967,13 +2009,13 @@ class _ChatListScreenState extends State ); } - Widget _buildFoldedStory(String imageUrl, int index) { + Widget _buildFoldedStory(ColorScheme cs, String imageUrl, int index) { return Positioned( left: index * 12.0, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: Colors.black, width: 2), + border: Border.all(color: cs.surface, width: 2), ), child: CircleAvatar( radius: 12, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 50f586d..28fb06d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 createState() => _ChatScreenState(); } -class _ChatScreenState extends State { +class _ChatScreenState extends State + with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); bool _hasText = false; - + bool _isLoading = true; + bool _isSending = false; + late AnimationController _shimmerController; + List _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 _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 { } } + Future _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 _loadForwardedSenderNames() async { + final forwardIds = {}; + 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 { ), 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 { 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 { 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 { 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, + ), ), ), ], diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index c47b562..3d86580 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -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 createState() => _ContactsTabState(); +} + +class _ContactsTabState extends State { + List _contacts = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + Future _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); + }, + ), + ), + ], ), ), ); diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart new file mode 100644 index 0000000..5f7de26 --- /dev/null +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -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( + 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)), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart new file mode 100644 index 0000000..8030921 --- /dev/null +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -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 createState() => _DevicesScreenState(); +} + +class _DevicesScreenState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = true; + List _sessions = []; + final Map> _ipDetails = {}; + final Set _loadingIps = {}; + final Set _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 _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 _showPasteQrDialog() async { + final tec = TextEditingController(); + try { + return await showDialog( + 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 _confirmQrWebLoginSheet() async { + final agreed = await showModalBottomSheet( + 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 _startWebQrAuth() async { + final canScan = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + final String? qr; + if (canScan) { + qr = await Navigator.push( + 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( + 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 _terminateOthers() async { + try { + await accountModule.terminateOtherSessions(); + if (mounted) { + showCustomNotification(context, 'Все сессии завершены'); + _loadSessions(); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + } + } + } + + Future _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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart new file mode 100644 index 0000000..dca05e4 --- /dev/null +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -0,0 +1,1488 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' show TwoFactorDetails; +import '../../../core/storage/app_database.dart'; +import '../../widgets/custom_notification.dart'; + +class PasswordEntryScreen extends StatefulWidget { + const PasswordEntryScreen({super.key}); + + @override + State createState() => _PasswordEntryScreenState(); +} + +class _PasswordEntryScreenState extends State { + bool _isLoading = true; + bool _is2faEnabled = false; + String? _email; + String? _hint; + + @override + void initState() { + super.initState(); + _check2faStatus(); + } + + Future _check2faStatus() async { + try { + final profile = await AppDatabase.loadActiveProfile(); + if (mounted) { + setState(() { + _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_isLoading) { + return Scaffold( + backgroundColor: cs.surface, + body: Center(child: CircularProgressIndicator(color: cs.primary)), + ); + } + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildMainSection(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Пароль для входа', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + Widget _buildMainSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _is2faEnabled ? Symbols.lock : Symbols.lock_open, + color: cs.primary, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _is2faEnabled + ? 'Пароль установлен' + : 'Пароль не установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _is2faEnabled + ? 'Используется для дополнительной защиты' + : 'Двухфакторная аутентификация', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Symbols.settings, + label: _is2faEnabled ? 'Изменить пароль' : 'Установить пароль', + isLast: _is2faEnabled, + onTap: () => _navigateToPasswordSetup(context, cs), + ), + if (_is2faEnabled) ...[ + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.email_outlined, + label: 'Изменить почту', + isLast: false, + onTap: () => _navigateToEmailChange(context, cs), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.delete_outline, + label: 'Удалить пароль', + isLast: true, + textColor: cs.error, + onTap: () => _showRemoveConfirmation(context, cs), + ), + ], + ], + ), + ); + } + + Widget _buildActionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool isLast, + required VoidCallback onTap, + Color? textColor, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + icon, + color: textColor ?? cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: textColor ?? cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + + void _navigateToPasswordSetup(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TwoFactorSetupScreen()), + ); + } + + void _navigateToEmailChange(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorEmailChangeScreen(), + ), + ); + } + + void _showRemoveConfirmation(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorRemoveScreen(), + ), + ); + }, + child: Text('Удалить', style: TextStyle(color: cs.error)), + ), + ], + ), + ); + } +} + +class TwoFactorSetupScreen extends StatefulWidget { + const TwoFactorSetupScreen({super.key}); + + @override + State createState() => _TwoFactorSetupScreenState(); +} + +class _TwoFactorSetupScreenState extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + final trackId = await accountModule.create2faTrack(); + setState(() { + _trackId = trackId; + _step = 1; + }); + break; + case 1: + if (_passwordController.text.length < 6) { + setState( + () => _errorMessage = 'Пароль должен быть минимум 6 символов', + ); + break; + } + await accountModule.set2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 2); + break; + case 2: + if (_hintController.text.isNotEmpty) { + await accountModule.set2faHint(_trackId!, _hintController.text); + } + setState(() => _step = 3); + break; + case 3: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 4); + break; + case 4: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.confirm2fa( + trackId: _trackId!, + password: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль установлен'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Установка пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _buildStepContent(cs), + ); + } + + Widget _buildStepContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStepIndicator(cs), + const SizedBox(height: 24), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.error, color: cs.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 14, + ), + ), + ), + ], + ), + ), + _buildCurrentStep(cs), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildStepIndicator(ColorScheme cs) { + final steps = ['Пароль', 'Подсказка', 'Почта', 'Код', 'Готово']; + return Row( + children: List.generate(steps.length, (index) { + final isActive = index <= _step; + final isCurrent = index == _step; + return Expanded( + child: Column( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? cs.primary : cs.surfaceContainerHighest, + ), + child: Center( + child: isActive + ? Icon(Symbols.check, color: cs.onPrimary, size: 16) + : Text( + '${index + 1}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + steps[index], + style: TextStyle( + color: isCurrent ? cs.primary : cs.onSurfaceVariant, + fontSize: 11, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + }), + ); + } + + Widget _buildCurrentStep(ColorScheme cs) { + switch (_step) { + case 0: + return _buildPasswordField(cs); + case 1: + return _buildPasswordConfirmField(cs); + case 2: + return _buildHintField(cs); + case 3: + return _buildEmailField(cs); + case 4: + return _buildCodeField(cs); + default: + return const SizedBox(); + } + } + + Widget _buildPasswordField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Придумайте пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Минимум 6 символов', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Введите пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildPasswordConfirmField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подтвердите пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Введите пароль ещё раз', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Повторите пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildHintField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подсказка для пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Необязательно', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildEmailField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Привяжите email', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Для восстановления пароля', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildCodeField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } +} + +class TwoFactorManageScreen extends StatefulWidget { + const TwoFactorManageScreen({super.key}); + + @override + State createState() => _TwoFactorManageScreenState(); +} + +class _TwoFactorManageScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _isAuthenticated = false; + String? _trackId; + TwoFactorDetails? _details; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _authenticate() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(_trackId!, _passwordController.text); + final details = await accountModule.get2faDetails(_trackId!); + setState(() { + _isAuthenticated = true; + _details = details; + }); + } catch (e) { + setState(() => _errorMessage = 'Неверный пароль'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Управление паролем', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _isAuthenticated ? _buildManageContent(cs) : _buildAuthContent(cs), + ); + } + + Widget _buildAuthContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _authenticate, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildManageContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Icon(Symbols.lock, color: cs.primary, size: 48), + const SizedBox(height: 12), + Text( + 'Пароль установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + if (_details?.email != null) ...[ + const SizedBox(height: 4), + Text( + _details!.email!, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + if (_details?.hint != null && _details!.hint!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Подсказка: ${_details!.hint}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorPasswordChangeScreen(), + ), + ); + }, + icon: const Icon(Symbols.edit), + label: const Text('Изменить пароль'), + style: OutlinedButton.styleFrom( + foregroundColor: cs.primary, + side: BorderSide(color: cs.outline), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ); + } +} + +class TwoFactorPasswordChangeScreen extends StatefulWidget { + const TwoFactorPasswordChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorPasswordChangeScreenState(); +} + +class _TwoFactorPasswordChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + super.dispose(); + } + + Future _changePassword() async { + if (_passwordController.text.length < 6) { + setState(() => _errorMessage = 'Пароль должен быть минимум 6 символов'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.update2faPassword( + trackId: trackId, + newPassword: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль изменён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Новый пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Введите новый пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Подсказка', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _changePassword, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Сохранить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorEmailChangeScreen extends StatefulWidget { + const TwoFactorEmailChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorEmailChangeScreenState(); +} + +class _TwoFactorEmailChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 1); + break; + case 1: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 2); + break; + case 2: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.update2faEmail( + trackId: _trackId!, + email: _emailController.text, + code: _codeController.text, + ); + if (mounted) { + showCustomNotification(context, 'Почта изменена'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить почту', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_step == 0) ...[ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ] else ...[ + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + if (_step == 1) ...[ + Text( + 'Новая почта', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ] else ...[ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorRemoveScreen extends StatefulWidget { + const TwoFactorRemoveScreen({super.key}); + + @override + State createState() => _TwoFactorRemoveScreenState(); +} + +class _TwoFactorRemoveScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _remove2fa() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.remove2fa(trackId); + if (mounted) { + showCustomNotification(context, 'Пароль удалён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Удаление пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.warning, color: cs.error), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Внимание! После удаления пароля защита вашего аккаунта ослабнет.', + style: TextStyle(color: cs.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Введите пароль для подтверждения', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _remove2fa, + style: FilledButton.styleFrom( + backgroundColor: cs.error, + foregroundColor: cs.onError, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onError, + ), + ) + : const Text('Удалить пароль'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart new file mode 100644 index 0000000..676396e --- /dev/null +++ b/lib/frontend/screens/profile/security_screen.dart @@ -0,0 +1,1129 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' + show PrivacyConfig, BlockedContact; +import '../../../core/storage/app_database.dart'; +import '../../widgets/custom_notification.dart'; +import 'password_entry_screen.dart'; + +class SecurityScreen extends StatefulWidget { + const SecurityScreen({super.key}); + + @override + State createState() => _SecurityScreenState(); +} + +class _SecurityScreenState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = true; + bool _isSaving = false; + bool _is2faEnabled = false; + PrivacyConfig? _privacyConfig; + List _blockedContacts = []; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadData(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadData() async { + try { + final results = await Future.wait([ + accountModule.getPrivacyConfig(), + accountModule.getBlockedContacts(), + AppDatabase.loadActiveProfile(), + ]); + if (mounted) { + setState(() { + _privacyConfig = results[0] as PrivacyConfig; + _blockedContacts = results[1] as List; + final profile = results[2] as ProfileData?; + _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка загрузки: $e'); + setState(() => _isLoading = false); + } + } + } + + Future _updateSetting(String key, dynamic value) async { + if (_isSaving) return; + setState(() => _isSaving = true); + try { + final newConfig = await accountModule.updatePrivacyConfig({key: value}); + if (mounted) { + setState(() => _privacyConfig = newConfig); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка сохранения: $e'); + } + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: _isLoading + ? _buildShimmer(cs) + : CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildTopSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildPrivacySettings(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: _buildInfoLabel(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildConfidentialSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + child: _buildBlacklistSection(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildShimmer(ColorScheme cs) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + _buildAppBar(context, cs), + _buildShimmerSection(cs, height: 104), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 280), + const SizedBox(height: 20), + _buildShimmerSection(cs, height: 220), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 120), + ], + ), + ); + } + + Widget _buildShimmerSection(ColorScheme cs, {required double height}) { + 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: 16), + child: Container( + height: height, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ); + }, + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Безопасность', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + const Spacer(), + if (_isSaving) + Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + ), + ], + ), + ); + } + + String _getPrivacyLabel(String value) { + switch (value) { + case 'ALL': + return 'Все'; + case 'CONTACTS': + return 'Мои контакты'; + case 'NONE': + return 'Никто'; + default: + return value; + } + } + + Widget _buildTopSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + _buildPasswordRow(cs), + _buildNavRow( + cs, + icon: Symbols.shield, + label: 'Семейная защита', + subtitle: _privacyConfig?.familyProtection == 'ON' + ? 'Включена' + : 'Отключена', + isLast: true, + ), + ], + ), + ); + } + + Widget _buildPasswordRow(ColorScheme cs) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PasswordEntryScreen(), + ), + ); + }, + borderRadius: BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.key, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Пароль для входа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _is2faEnabled ? 'Включён' : 'Отключён', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + _buildWarningBadge(cs), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildPrivacySettings(ColorScheme cs) { + final isSafeMode = _privacyConfig?.safeMode ?? false; + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.lock, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Безопасный режим', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Скрывает личную информацию', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: isSafeMode, + onChanged: (v) => showCustomNotification( + context, + 'Изменение настроек пока недоступно', + ), + ), + ], + ), + ), + if (isSafeMode) ...[ + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildSubRow( + cs, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Показывать контакт', + value: _privacyConfig?.contentLevelAccess == true + ? 'Безопасный' + : 'Весь', + isLast: true, + ), + ], + if (!isSafeMode) ...[ + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.phone, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может мне звонить', + currentValue: _privacyConfig?.incomingCall ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('INCOMING_CALL', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.group, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может приглашать в чаты', + currentValue: _privacyConfig?.chatsInvite ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('CHATS_INVITE', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.contact_phone, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Найти меня по номеру', + currentValue: _privacyConfig?.searchByPhone ?? 'ALL', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('SEARCH_BY_PHONE', value), + ), + ), + _buildOptionRow( + cs, + icon: Icons.visibility_off_outlined, + label: 'Видеть статус «в сети»', + value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты', + isLast: true, + onTap: () => _showHiddenStatusSheet(context, cs), + ), + ], + ], + ), + ); + } + + void _showOptionSheet( + BuildContext context, + ColorScheme cs, { + required String title, + required String currentValue, + required List<(String, String)> options, + required void Function(String) onSelect, + }) { + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...options.map((option) { + final isSelected = option.$1 == currentValue; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.pop(context); + onSelect(option.$1); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Row( + children: [ + Expanded( + child: Text( + option.$2, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ); + }), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) { + final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; + + if (currentValue == 'NONE') { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + return; + } + + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + 'Видеть статус «в сети»', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _buildOptionSheetItem( + cs, + 'Мои контакты', + currentValue == 'CONTACTS', + () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + ), + _buildOptionSheetItem(cs, 'Никто', currentValue == 'NONE', () { + Navigator.pop(context); + _showHiddenStatusConfirmDialog(context, cs); + }, isLast: true), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', true); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + } + + Widget _buildOptionSheetItem( + ColorScheme cs, + String label, + bool isSelected, + VoidCallback onTap, { + bool isLast = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + + Widget _buildInfoLabel(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 0), + child: Text( + 'КОНФИДЕНЦИАЛЬНОСТЬ', + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } + + Widget _buildConfidentialSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + _buildSwitchRow( + cs, + icon: Symbols.description, + label: 'Галочки «Прочитано»', + value: _privacyConfig?.showReadMark ?? true, + isLast: false, + onChanged: (v) => _updateSetting('SHOW_READ_MARK', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.keyboard_alt, + label: 'Альтернативная клавиатура', + value: _privacyConfig?.altKeyboard ?? false, + isLast: false, + onChanged: (v) => _updateSetting('ALT_KEYBOARD', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.warning, + label: 'Принимать опасные файлы', + value: _privacyConfig?.unsafeFiles ?? true, + isLast: false, + onChanged: (v) => _updateSetting('UNSAFE_FILES', v), + ), + _buildSwitchRow( + cs, + icon: Icons.mic_none_outlined, + label: 'Транскрибация аудио', + value: _privacyConfig?.audioTranscriptionEnabled ?? true, + isLast: true, + onChanged: (v) => _updateSetting('AUDIO_TRANSCRIPTION_ENABLED', v), + ), + ], + ), + ); + } + + Widget _buildBlacklistSection(ColorScheme cs) { + final count = _blockedContacts.length; + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification( + context, + 'Чёрный список: $count контактов', + ), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.block, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Чёрный список', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '$count ${_getBlockedCountText(count)}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + ); + } + + String _getBlockedCountText(int count) { + if (count == 0) return 'контактов'; + final mod = count % 10; + if (mod == 1 && count != 11) return 'контакт'; + if (mod >= 2 && mod <= 4 && (count < 10 || count > 20)) return 'контакта'; + return 'контактов'; + } + + Widget _buildNavRow( + ColorScheme cs, { + required IconData icon, + required String label, + String? subtitle, + String? value, + Widget? trailing, + required bool isLast, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, label), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: subtitle != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ) + : Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + if (value != null) + Text( + value, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + if (trailing != null) trailing, + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildOptionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required String value, + required bool isLast, + required VoidCallback onTap, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + value, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildSubRow( + ColorScheme cs, { + required String label, + required String value, + required bool isLast, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, label), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + ), + Text( + value, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 18, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + indent: 20, + endIndent: 20, + ), + ], + ); + } + + Widget _buildSwitchRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool value, + required bool isLast, + required void Function(bool) onChanged, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildWarningBadge(ColorScheme cs) { + return Container( + width: 22, + height: 22, + decoration: BoxDecoration(color: cs.error, shape: BoxShape.circle), + child: Icon( + Symbols.priority_high, + color: cs.onError, + size: 14, + weight: 700, + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 95e1f6a..2389838 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -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 createState() => _SettingsTabState(); +} + +class _SettingsTabState extends State { + 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 _loadProfile() async { + final p = await AppDatabase.loadActiveProfile(); + if (mounted) setState(() => _profile = p); + } + + Future _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( + 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: [ + ...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 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; } diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart new file mode 100644 index 0000000..1a1f724 --- /dev/null +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -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 createState() => _SpoofScreenState(); +} + +class _SpoofScreenState extends State { + 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.generate(8, (_) => _random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + Future _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 _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 _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 _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 _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( + 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 _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( + 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( + 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({ + required List<_ChipOption> options, + required T selected, + required ValueChanged 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 { + final T value; + final String label; + final IconData? icon; + + const _ChipOption(this.value, this.label, [this.icon]); +} diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart new file mode 100644 index 0000000..a859b5a --- /dev/null +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -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 createState() => _WebQrScanScreenState(); +} + +class _WebQrScanScreenState extends State { + 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(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( + 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? _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 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( + 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 _mapBarcodeCornersToLayout( + List 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, + ), + ]; +} + diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 85c6e91..5bdb22b 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -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), ); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e69de29..74c4ed9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -0,0 +1,1769 @@ +import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/main.dart'; +import 'package:flutter/foundation.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../backend/modules/messages.dart'; +import '../../models/attachment.dart'; + +enum MessageType { text, attachment, voice } + +enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } + +class MessageBubble extends StatelessWidget { + static const double photoMaxSize = 280.0; + static const double photoMinSize = 100.0; + static const double photoBorderRadius = 12.0; + static const double bubbleBorderRadius = 20.0; + static const double captionPaddingHorizontal = 6.0; + static const double captionPaddingRight = 4.0; + static const double compactTimePadding = 8.0; + + bool get _hasPhotoWithCaption { + if (message.attachments == null || message.attachments!.isEmpty) + return false; + final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment); + final hasCaption = message.text != null && message.text!.isNotEmpty; + return hasPhoto && hasCaption; + } + + bool get _hasMultiplePhotosNoCaption { + if (message.attachments == null || message.attachments!.isEmpty) + return false; + final photoCount = message.attachments!.whereType().length; + final hasCaption = message.text != null && message.text!.isNotEmpty; + return photoCount >= 2 && !hasCaption; + } + + final CachedMessage message; + final bool isMe; + final int myId; + final CachedMessage? prevMessage; + final CachedMessage? nextMessage; + final String chatType; + + const MessageBubble({ + super.key, + required this.message, + required this.isMe, + required this.myId, + this.prevMessage, + this.nextMessage, + required this.chatType + }); + + bool get isGroupedWithNext { + if (nextMessage == null) return false; + if (nextMessage!.senderId != message.senderId) return false; + final timeDiff = nextMessage!.time - message.time; + return timeDiff < 300000; + } + + BubbleShape get shape { + final hasPrevFromMe = prevMessage?.senderId == message.senderId; + final prevTimeDiff = hasPrevFromMe + ? message.time - prevMessage!.time + : 999999999; + + final hasNextFromMe = nextMessage?.senderId == message.senderId; + final nextTimeDiff = hasNextFromMe + ? nextMessage!.time - message.time + : 999999999; + + final bool groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; + final bool groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; + + if (!groupedWithPrev && !groupedWithNext) return BubbleShape.singleMiddle; + if (!groupedWithPrev && groupedWithNext) return BubbleShape.singleTop; + if (groupedWithPrev && !groupedWithNext) return BubbleShape.singleBottom; + + return BubbleShape.groupedMiddle; + } + + MessageType get contentType { + if (message.attachments != null && message.attachments!.isNotEmpty) { + final first = message.attachments!.first; + if (first is ForwardedMessageAttachment) { + final fwd = first; + final hasContact = fwd.originalContact != null; + final hasPhoto = + fwd.originalAttachments != null && + fwd.originalAttachments!.any((a) => a is PhotoAttachment); + final hasOther = + fwd.originalAttachments != null && + fwd.originalAttachments!.isNotEmpty; + if (hasContact || hasPhoto || hasOther) return MessageType.attachment; + return MessageType.text; + } + if (first is ContactAttachment) return MessageType.attachment; + if (first is UnknownAttachment) return MessageType.text; + if (first.type == AttachmentType.audio) return MessageType.voice; + return MessageType.attachment; + } + + final payload = message.payload; + if (payload == null) return MessageType.text; + + final voice = payload['voice']; + if (voice != null) return MessageType.voice; + + return MessageType.text; + } + + // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие + BorderRadius get _borderRadius { + final topRadius = Radius.circular(bubbleBorderRadius); + final bottomRadius = Radius.circular(bubbleBorderRadius); + final smallRadius = const Radius.circular(4); + + if (_hasPhotoWithCaption && + (shape == BubbleShape.singleTop || + shape == BubbleShape.singleMiddle || + shape == BubbleShape.singleBottom)) { + return BorderRadius.only( + topLeft: topRadius, + topRight: isMe ? topRadius : topRadius, + bottomLeft: smallRadius, + bottomRight: smallRadius, + ); + } + + if (_hasMultiplePhotosNoCaption && + (shape == BubbleShape.singleBottom || + shape == BubbleShape.singleMiddle)) { + return BorderRadius.only( + topLeft: smallRadius, + topRight: smallRadius, + bottomLeft: isMe ? smallRadius : smallRadius, + bottomRight: isMe ? smallRadius : bottomRadius, + ); + } + + switch (shape) { + case BubbleShape.singleTop: + return BorderRadius.only( + topLeft: isMe ? topRadius : smallRadius, + topRight: isMe ? smallRadius : topRadius, + bottomLeft: smallRadius, + bottomRight: smallRadius, + ); + case BubbleShape.singleBottom: + return BorderRadius.only( + topLeft: smallRadius, + topRight: smallRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : topRadius, + ); + case BubbleShape.singleMiddle: + return BorderRadius.only( + topLeft: topRadius, + topRight: topRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : topRadius, + ); + case BubbleShape.groupedMiddle: + return BorderRadius.only( + topLeft: isMe ? topRadius : smallRadius, + topRight: isMe ? smallRadius : smallRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : smallRadius, + ); + } + } + + // ВРОДЕ отступы между сообщениями, если они выглядят адекватно не трогайте пж я ради них кишку на шею намотал + // ОТСТУПЫ МЕЖДУ СООБЩЕНИЯМИ (top/bottom margin) + // Зависит от shape (группировка) И contentType (тип контента) + double get topMargin { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.singleTop: + return 6; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 6; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.voice: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + } + return 4; + } + + double get bottomMargin { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.voice: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + } + return 4; + } + + // Внутренний отступ, размеры типа я хз + EdgeInsets get padding { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + case BubbleShape.singleTop: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleBottom: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + case MessageType.attachment: + return const EdgeInsets.all(0); + case MessageType.voice: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + case BubbleShape.singleTop: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleBottom: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + } + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? senderAvatar = ContactCache.getAvatar(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + + return Padding( + padding: EdgeInsets.only( + left: isMe ? 60 : 12, + right: isMe ? 12 : 60, + top: topMargin, + bottom: bottomMargin, + ), + child: Align( + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + spacing: 8.0, + children: [ + if (senderAvatar != null && senderAvatar.isNotEmpty && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else if (displaySender != null && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender!.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + // Заглушка для паддинга + else + CircleAvatar( + radius: 15, + backgroundColor: Color(0x00000000) + ), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), + ), + ], + ) + ), + ); + } + + Widget _buildContent(BuildContext context) { + switch (contentType) { + case MessageType.attachment: + return _buildAttachmentContent(context); + case MessageType.voice: + return _buildVoiceContent(context); + case MessageType.text: + default: + return _buildTextContent(context); + } + } + + Widget _buildTextContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + final attachments = message.attachments; + final isForwardedContact = + attachments != null && + attachments.isNotEmpty && + attachments.first is ForwardedMessageAttachment && + (attachments.first as ForwardedMessageAttachment).originalContact != + null; + + final forwarded = _getForwardedAttachment(); + final isForwarded = forwarded != null && !isForwardedContact; + + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId) + Text( + displaySender ?? "", + textAlign: TextAlign.left, + // TODO: Получение цветов по хешу ника + style: TextStyle(color: cs.onPrimaryContainer) + ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: isForwarded + ? _buildForwardedInlineText(context, forwarded, textColor) + : Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 10, + ), + ), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], + ), + ], + ); + } + + Widget _buildForwardedInlineText( + BuildContext context, + ForwardedMessageAttachment forwarded, + Color textColor, + ) { + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + final origText = forwarded.originalText; + final hasOrigText = origText != null && origText.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (hasOrigText) ...[ + const SizedBox(height: 2), + Text( + origText, + style: TextStyle(color: textColor, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ] else ...[ + const SizedBox(height: 2), + Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ], + ], + ); + } + + ForwardedMessageAttachment? _getForwardedAttachment() { + if (message.attachments == null || message.attachments!.isEmpty) + return null; + for (final a in message.attachments!) { + if (a is ForwardedMessageAttachment) return a; + } + return null; + } + + Widget _buildForwardedHeader( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final origText = forwarded.originalText; + final hasOrigText = origText != null && origText.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + if (hasOrigText) ...[ + const SizedBox(height: 2), + Text( + origText, + style: TextStyle(color: textColor, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ); + } + + Widget _buildAttachmentContent(BuildContext context) { + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) { + return _buildTextContent(context); + } + + final first = attachments.first; + if (first is ForwardedMessageAttachment) { + final fwd = first; + if (fwd.originalContact != null) { + return _buildForwardedContactContent(context, fwd); + } + final photos = fwd.originalAttachments + ?.whereType() + .toList(); + if (photos != null && photos.isNotEmpty) { + return _buildForwardedPhotoContent(context, fwd, photos); + } + final files = fwd.originalAttachments; + if (files != null && files.isNotEmpty) { + return _buildForwardedGenericContent(context, fwd, files); + } + return _buildTextContent(context); + } + + final contacts = attachments.whereType().toList(); + if (contacts.isNotEmpty) { + return _buildContactAttachment(context, contacts.first); + } + + final photos = attachments.whereType().toList(); + if (photos.isEmpty) { + return _buildGenericAttachment(context, attachments.first); + } + + return _buildPhotoContent(context, photos); + } + + Widget _buildPhotoContent(BuildContext ctx, List photos) { + final hasCaption = message.text != null && message.text!.isNotEmpty; + final count = photos.length; + + Widget photosWidget; + if (count == 1) { + photosWidget = _buildSinglePhoto(ctx, photos[0]); + } else if (count == 2) { + photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); + } else { + photosWidget = _buildPhotoGrid(ctx, photos); + } + + if (!hasCaption) { + return Stack( + children: [ + photosWidget, + Positioned( + bottom: compactTimePadding, + right: compactTimePadding, + child: _buildCompactTime(ctx), + ), + ], + ); + } + + if (count == 1) { + final photo = photos[0]; + final pw = photo.width?.toDouble() ?? 200; + final photoWidth = pw.clamp(photoMinSize, photoMaxSize); + + return SizedBox( + width: photoWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: captionPaddingHorizontal, + right: captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: _buildCaption(ctx)), + _buildMeta(ctx), + ], + ), + ), + ], + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: captionPaddingHorizontal, + right: captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildCaption(ctx)), + _buildMeta(ctx), + ], + ), + ), + ], + ); + } + + Widget _buildForwardedPhotoContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List photos, + ) { + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + final hasCaption = message.text != null && message.text!.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + if (hasCaption) ...[ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + message.text ?? '', + style: TextStyle( + color: isMe ? Colors.white : const Color(0xFF1C1C1E), + fontSize: 16, + height: 1.3, + ), + ), + ), + const SizedBox(height: 6), + ], + _buildPhotoContent(context, photos), + ], + ); + } + + Widget _buildForwardedFileContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List files, + ) { + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...files.map((file) => _buildFileAttachment(context, file)), + ], + ); + } + + Widget _buildForwardedGenericContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List attachments, + ) { + debugPrint('DEBUG: _buildForwardedGenericContent called'); + debugPrint('DEBUG: attachments count: ${attachments.length}'); + for (var i = 0; i < attachments.length; i++) { + debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}'); + debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}'); + } + + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...attachments.map((a) { + if (a is FileAttachment) { + return _buildFileAttachment(context, a); + } + return const SizedBox.shrink(); + }), + ], + ); + } + + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { + final imageUrl = photo.baseUrl ?? ''; + final width = photo.width?.toDouble() ?? 200; + final height = photo.height?.toDouble() ?? 200; + + final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); + final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); + + final bool matchTop = _hasPhotoWithCaption; + final bool matchBottom = !_hasPhotoWithCaption; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular( + matchBottom ? (isMe ? bubbleBorderRadius : 4) : 4, + ), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : 4, + ), + ), + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + width: constrainedWidth, + height: constrainedHeight, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPhotoPlaceholder( + ctx, + constrainedWidth, + constrainedHeight, + ), + ) + else + _buildPhotoPlaceholder(ctx, constrainedWidth, constrainedHeight), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), + ), + ), + ], + ), + ); + } + + Widget _buildTwoPhotos( + BuildContext ctx, + PhotoAttachment p1, + PhotoAttachment p2, + ) { + final matchTop = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + final matchBottom = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildPhotoTile(ctx, p1), + const SizedBox(width: 2), + _buildPhotoTile(ctx, p2), + ], + ), + ); + } + + Widget _buildPhotoGrid(BuildContext ctx, List photos) { + final displayCount = photos.length > 4 ? 4 : photos.length; + final remaining = photos.length - 4; + + final matchTop = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + final matchBottom = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, + ), + ), + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: List.generate(displayCount, (i) { + if (i == 3 && remaining > 0) { + return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining'); + } + return _buildPhotoTile(ctx, photos[i]); + }), + ), + ); + } + + Widget _buildPhotoTile(BuildContext ctx, PhotoAttachment photo) { + final imageUrl = photo.baseUrl ?? ''; + return Expanded( + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx, 100, 100), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPhotoTileWithOverlay( + BuildContext ctx, + PhotoAttachment photo, + String overlay, + ) { + final imageUrl = photo.baseUrl ?? ''; + return Expanded( + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx, 100, 100), + Positioned.fill( + child: Container( + color: Colors.black45, + child: Center( + child: Text( + overlay, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPhotoPlaceholder( + BuildContext ctx, + double w, + double h, { + VoidCallback? onRetry, + }) { + final cs = Theme.of(ctx).colorScheme; + return Container( + width: w, + height: h, + color: cs.surfaceContainerHighest, + child: onRetry != null + ? Center( + child: IconButton( + icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant), + onPressed: onRetry, + tooltip: 'Retry', + ), + ) + : Center( + child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + ), + ); + } + + Widget _buildCaption(BuildContext ctx) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + return Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ); + } + + Widget _buildGenericAttachment( + BuildContext ctx, + MessageAttachment attachment, + ) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + switch (attachment.type) { + case AttachmentType.video: + return _buildVideoAttachment(ctx, attachment); + case AttachmentType.file: + return _buildFileAttachment(ctx, attachment); + case AttachmentType.sticker: + return _buildStickerAttachment(ctx, attachment); + default: + return _buildTextContent(ctx); + } + } + + Widget _buildVideoAttachment(BuildContext ctx, MessageAttachment video) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(photoBorderRadius), + child: Stack( + children: [ + Container( + width: 200, + height: 150, + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + child: Icon( + Symbols.videocam, + size: 48, + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () {}), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final name = (file as dynamic).name as String? ?? 'File'; + final size = (file as dynamic).size as int? ?? 0; + final sizeStr = _formatFileSize(size); + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.description, + color: isMe ? Colors.white : cs.primary, + size: 20, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + sizeStr, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.15) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE5E5EA)), + shape: BoxShape.circle, + ), + child: Icon( + Symbols.download, + color: isMe ? Colors.white : cs.primary, + size: 18, + ), + ), + ), + ], + ), + ); + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ'; + } + + Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { + final preview = (sticker as dynamic).previewData as String? ?? ''; + + return ClipRRect( + borderRadius: BorderRadius.circular(photoBorderRadius), + child: Stack( + children: [ + if (preview.isNotEmpty) + Image.network( + preview, + width: 150, + height: 150, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 150, 150), + ) + else + _buildPhotoPlaceholder(ctx, 150, 150), + ], + ), + ); + } + + Widget _buildContactAttachment(BuildContext ctx, MessageAttachment contact) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contactData = contact as ContactAttachment; + + final firstName = contactData.firstName ?? ''; + final lastName = contactData.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contactData.name ?? 'Contact'); + final photoUrl = contactData.photoUrl ?? contactData.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contactData.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contactData.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildForwardedContactContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contact = forwarded.originalContact!; + + final firstName = contact.firstName ?? ''; + final lastName = contact.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contact.name ?? 'Contact'); + final photoUrl = contact.photoUrl ?? contact.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contact.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contact.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ], + ); + } + + void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { + // TODO: Open photo viewer + } + + Widget _buildVoiceContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final payload = message.payload; + final voice = payload?['voice'] as Map?; + final duration = voice?['duration'] as int? ?? 0; + final url = voice?['url']?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _VoiceMessageBubble( + duration: duration, + url: url, + textColor: textColor, + isMe: isMe, + ), + const SizedBox(height: 6), + _buildMeta(context), + ], + ); + } + + Widget _buildMeta(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + _formatTime(message.time), + style: TextStyle(color: timeColor, fontSize: 11), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], + ), + ); + } + + Widget _buildCompactTime(BuildContext ctx) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final bgColor = isMe + ? Colors.black.withValues(alpha: 0.4) + : Colors.black.withValues(alpha: 0.5); + final textColor = Colors.white; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildStatusIcon(BuildContext context) { + final status = message.status; + IconData icon; + Color color; + + if (status == null || status == 'sending' || status == 'pending') { + icon = Symbols.check; + color = Colors.white70; + } else { + switch (status) { + case 'sent': + icon = Symbols.check; + color = Colors.white70; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white70; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF34C759); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white70; + } + } + + return Icon(icon, size: 14, color: color); + } + + String _formatTime(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } +} + +class _VoiceMessageBubble extends StatefulWidget { + final int duration; + final String url; + final Color textColor; + final bool isMe; + + const _VoiceMessageBubble({ + required this.duration, + required this.url, + required this.textColor, + required this.isMe, + }); + + @override + State<_VoiceMessageBubble> createState() => _VoiceMessageBubbleState(); +} + +class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { + bool _isPlaying = false; + double _progress = 0.0; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + + return Container( + width: 220, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe ? Colors.white : cs.primary, + size: 20, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Container( + height: 24, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFD1D1D6)), + borderRadius: BorderRadius.circular(2), + ), + ), + FractionallySizedBox( + widthFactor: _progress.clamp(0.0, 1.0), + child: Container( + height: 24, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.5) + : cs.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + SizedBox( + height: 24, + child: Center( + child: Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + void _togglePlay() { + setState(() { + _isPlaying = !_isPlaying; + }); + } + + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..806d0c5 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,108 @@ +{ + "@@locale": "en", + "loginTitle": "Sign in to Komet", + "loginSubtitle": "Check your country code and enter your\nphone number.", + "loginCountry": "Country", + "loginPhoneNumber": "Phone number", + "loginOtherSignInMethods": "Other sign-in methods", + "loginTermsIntro": "By continuing, you agree to \n", + "loginTermsLink": "the terms of use", + "loginTermsOfUse": "Terms of use", + "loginConfirmPhoneTitle": "Is this the correct number?", + "loginEdit": "Change", + "loginDone": "Done", + "loginReadTermsNotification": "Please read the terms of use first", + "loginSpoofRedacted": "Spoofing", + "loginProxy": "Proxy", + "loginChangeServer": "Change server", + "serverSettingsTitle": "Server", + "serverHostLabel": "Host", + "serverPortLabel": "Port", + "serverApply": "Apply and reconnect", + "serverUseDefault": "Reset to default", + "serverInvalidHostOrPort": "Enter a valid host and port (1–65535)", + "serverSettingsSaved": "Server settings applied", + "serverReconnectFailed": "Could not connect to the server", + "loginSignInWithQr": "Sign in with QR code", + "loginSignInWithToken": "Sign in with token", + "loginSignInWithSessionFile": "Sign in with session file", + "loginLanguage": "Language", + "languageNameRu": "Русский", + "languageNameEn": "English", + "selectCountryTitle": "Select country", + "selectCountrySearchHint": "Search countries…", + "codeConfirmationSmsSent": "We sent an SMS with a verification code to your phone number.", + "codeResendInSeconds": "Resend in {seconds} s.", + "@codeResendInSeconds": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "codeResendSms": "Resend code via SMS", + "codeError2faMissing": "Error: missing data for 2FA", + + "proxySettingsTitle": "Proxy", + "proxyTypeNone": "Disabled", + "proxyTypeSocks5": "SOCKS5", + "proxyTypeHttp": "HTTP(S)", + "proxyHostLabel": "Proxy host", + "proxyPortLabel": "Proxy port", + "proxyUsernameLabel": "Username (optional)", + "proxyPasswordLabel": "Password (optional)", + "proxyApply": "Apply and reconnect", + "proxyDisable": "Disable proxy", + "proxySettingsSaved": "Proxy settings applied", + "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)", + + "spoofScreenTitle": "Session spoofing", + "spoofInfoHint": "Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.", + "spoofMethodTitle": "Spoofing method", + "spoofMethodPartial": "Partial", + "spoofMethodFull": "Full", + "spoofMethodPartialDescription": "Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.", + "spoofMethodFullDescription": "All data including timezone and locale is generated randomly. Use this method at your own risk!", + "spoofDeviceTypeTitle": "Device type", + "spoofDeviceTypeDescription": "This field is not changeable, to avoid token association issues", + "spoofDeviceTypeLabel": "Device type", + "spoofMainSectionTitle": "Main data", + "spoofFieldDeviceName": "Device name", + "spoofFieldOsVersion": "OS version", + "spoofRegionalSectionTitle": "Regional data", + "spoofFieldScreen": "Screen resolution", + "spoofFieldTimezone": "Timezone", + "spoofFieldLocale": "Locale", + "spoofIdentifiersSectionTitle": "Identifiers", + "spoofIdentifiersDescription": "mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.", + "spoofFieldDeviceId": "Device ID", + "spoofRegenerateIdTooltip": "Generate a new ID", + "spoofFieldAppVersion": "App version", + "spoofFieldBuildNumber": "Build number", + "spoofFieldArchitecture": "Architecture", + "spoofButtonGenerate": "Generate", + "spoofButtonApply": "Apply", + "spoofDialogUnsureTitle": "Are you sure?", + "spoofDialogUnsureContent": "The app may become unstable due to API incompatibility", + "spoofDialogCancel": "Cancel", + "spoofDialogYes": "Yes", + "spoofDialogApplyTitle": "Apply settings?", + "spoofDialogApplyContent": "Need to reconnect the app, ok?", + "spoofDialogApplyWarning": "Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible", + "spoofDialogReloginTitle": "Done!", + "spoofDialogReloginContent": "Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.", + "spoofDialogReloginWarning": "Re-login now?", + "spoofDialogReloginDeny": "Later", + "spoofDialogReloginConfirm": "Re-login now", + "spoofDialogApplyDeny": "No", + "spoofDialogApplyConfirm": "Ok!", + "spoofErrorApplyFailed": "Failed to apply settings: {error}", + "@spoofErrorApplyFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "profileMenuSpoof": "Spoofing" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..5bd5013 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,668 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ru.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate 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> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ru'), + ]; + + /// No description provided for @loginTitle. + /// + /// In en, this message translates to: + /// **'Sign in to Komet'** + String get loginTitle; + + /// No description provided for @loginSubtitle. + /// + /// In en, this message translates to: + /// **'Check your country code and enter your\nphone number.'** + String get loginSubtitle; + + /// No description provided for @loginCountry. + /// + /// In en, this message translates to: + /// **'Country'** + String get loginCountry; + + /// No description provided for @loginPhoneNumber. + /// + /// In en, this message translates to: + /// **'Phone number'** + String get loginPhoneNumber; + + /// No description provided for @loginOtherSignInMethods. + /// + /// In en, this message translates to: + /// **'Other sign-in methods'** + String get loginOtherSignInMethods; + + /// No description provided for @loginTermsIntro. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to \n'** + String get loginTermsIntro; + + /// No description provided for @loginTermsLink. + /// + /// In en, this message translates to: + /// **'the terms of use'** + String get loginTermsLink; + + /// No description provided for @loginTermsOfUse. + /// + /// In en, this message translates to: + /// **'Terms of use'** + String get loginTermsOfUse; + + /// No description provided for @loginConfirmPhoneTitle. + /// + /// In en, this message translates to: + /// **'Is this the correct number?'** + String get loginConfirmPhoneTitle; + + /// No description provided for @loginEdit. + /// + /// In en, this message translates to: + /// **'Change'** + String get loginEdit; + + /// No description provided for @loginDone. + /// + /// In en, this message translates to: + /// **'Done'** + String get loginDone; + + /// No description provided for @loginReadTermsNotification. + /// + /// In en, this message translates to: + /// **'Please read the terms of use first'** + String get loginReadTermsNotification; + + /// No description provided for @loginSpoofRedacted. + /// + /// In en, this message translates to: + /// **'Spoofing'** + String get loginSpoofRedacted; + + /// No description provided for @loginProxy. + /// + /// In en, this message translates to: + /// **'Proxy'** + String get loginProxy; + + /// No description provided for @loginChangeServer. + /// + /// In en, this message translates to: + /// **'Change server'** + String get loginChangeServer; + + /// No description provided for @serverSettingsTitle. + /// + /// In en, this message translates to: + /// **'Server'** + String get serverSettingsTitle; + + /// No description provided for @serverHostLabel. + /// + /// In en, this message translates to: + /// **'Host'** + String get serverHostLabel; + + /// No description provided for @serverPortLabel. + /// + /// In en, this message translates to: + /// **'Port'** + String get serverPortLabel; + + /// No description provided for @serverApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get serverApply; + + /// No description provided for @serverUseDefault. + /// + /// In en, this message translates to: + /// **'Reset to default'** + String get serverUseDefault; + + /// No description provided for @serverInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid host and port (1–65535)'** + String get serverInvalidHostOrPort; + + /// No description provided for @serverSettingsSaved. + /// + /// In en, this message translates to: + /// **'Server settings applied'** + String get serverSettingsSaved; + + /// No description provided for @serverReconnectFailed. + /// + /// In en, this message translates to: + /// **'Could not connect to the server'** + String get serverReconnectFailed; + + /// No description provided for @loginSignInWithQr. + /// + /// In en, this message translates to: + /// **'Sign in with QR code'** + String get loginSignInWithQr; + + /// No description provided for @loginSignInWithToken. + /// + /// In en, this message translates to: + /// **'Sign in with token'** + String get loginSignInWithToken; + + /// No description provided for @loginSignInWithSessionFile. + /// + /// In en, this message translates to: + /// **'Sign in with session file'** + String get loginSignInWithSessionFile; + + /// No description provided for @loginLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get loginLanguage; + + /// No description provided for @languageNameRu. + /// + /// In en, this message translates to: + /// **'Русский'** + String get languageNameRu; + + /// No description provided for @languageNameEn. + /// + /// In en, this message translates to: + /// **'English'** + String get languageNameEn; + + /// No description provided for @selectCountryTitle. + /// + /// In en, this message translates to: + /// **'Select country'** + String get selectCountryTitle; + + /// No description provided for @selectCountrySearchHint. + /// + /// In en, this message translates to: + /// **'Search countries…'** + String get selectCountrySearchHint; + + /// No description provided for @codeConfirmationSmsSent. + /// + /// In en, this message translates to: + /// **'We sent an SMS with a verification code to your phone number.'** + String get codeConfirmationSmsSent; + + /// No description provided for @codeResendInSeconds. + /// + /// In en, this message translates to: + /// **'Resend in {seconds} s.'** + String codeResendInSeconds(int seconds); + + /// No description provided for @codeResendSms. + /// + /// In en, this message translates to: + /// **'Resend code via SMS'** + String get codeResendSms; + + /// No description provided for @codeError2faMissing. + /// + /// In en, this message translates to: + /// **'Error: missing data for 2FA'** + String get codeError2faMissing; + + /// No description provided for @proxySettingsTitle. + /// + /// In en, this message translates to: + /// **'Proxy'** + String get proxySettingsTitle; + + /// No description provided for @proxyTypeNone. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get proxyTypeNone; + + /// No description provided for @proxyTypeSocks5. + /// + /// In en, this message translates to: + /// **'SOCKS5'** + String get proxyTypeSocks5; + + /// No description provided for @proxyTypeHttp. + /// + /// In en, this message translates to: + /// **'HTTP(S)'** + String get proxyTypeHttp; + + /// No description provided for @proxyHostLabel. + /// + /// In en, this message translates to: + /// **'Proxy host'** + String get proxyHostLabel; + + /// No description provided for @proxyPortLabel. + /// + /// In en, this message translates to: + /// **'Proxy port'** + String get proxyPortLabel; + + /// No description provided for @proxyUsernameLabel. + /// + /// In en, this message translates to: + /// **'Username (optional)'** + String get proxyUsernameLabel; + + /// No description provided for @proxyPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password (optional)'** + String get proxyPasswordLabel; + + /// No description provided for @proxyApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get proxyApply; + + /// No description provided for @proxyDisable. + /// + /// In en, this message translates to: + /// **'Disable proxy'** + String get proxyDisable; + + /// No description provided for @proxySettingsSaved. + /// + /// In en, this message translates to: + /// **'Proxy settings applied'** + String get proxySettingsSaved; + + /// No description provided for @proxyInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid proxy host and port (1–65535)'** + String get proxyInvalidHostOrPort; + + /// No description provided for @spoofScreenTitle. + /// + /// In en, this message translates to: + /// **'Session spoofing'** + String get spoofScreenTitle; + + /// No description provided for @spoofInfoHint. + /// + /// In en, this message translates to: + /// **'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.'** + String get spoofInfoHint; + + /// No description provided for @spoofMethodTitle. + /// + /// In en, this message translates to: + /// **'Spoofing method'** + String get spoofMethodTitle; + + /// No description provided for @spoofMethodPartial. + /// + /// In en, this message translates to: + /// **'Partial'** + String get spoofMethodPartial; + + /// No description provided for @spoofMethodFull. + /// + /// In en, this message translates to: + /// **'Full'** + String get spoofMethodFull; + + /// No description provided for @spoofMethodPartialDescription. + /// + /// In en, this message translates to: + /// **'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.'** + String get spoofMethodPartialDescription; + + /// No description provided for @spoofMethodFullDescription. + /// + /// In en, this message translates to: + /// **'All data including timezone and locale is generated randomly. Use this method at your own risk!'** + String get spoofMethodFullDescription; + + /// No description provided for @spoofDeviceTypeTitle. + /// + /// In en, this message translates to: + /// **'Device type'** + String get spoofDeviceTypeTitle; + + /// No description provided for @spoofDeviceTypeDescription. + /// + /// In en, this message translates to: + /// **'This field is not changeable, to avoid token association issues'** + String get spoofDeviceTypeDescription; + + /// No description provided for @spoofDeviceTypeLabel. + /// + /// In en, this message translates to: + /// **'Device type'** + String get spoofDeviceTypeLabel; + + /// No description provided for @spoofMainSectionTitle. + /// + /// In en, this message translates to: + /// **'Main data'** + String get spoofMainSectionTitle; + + /// No description provided for @spoofFieldDeviceName. + /// + /// In en, this message translates to: + /// **'Device name'** + String get spoofFieldDeviceName; + + /// No description provided for @spoofFieldOsVersion. + /// + /// In en, this message translates to: + /// **'OS version'** + String get spoofFieldOsVersion; + + /// No description provided for @spoofRegionalSectionTitle. + /// + /// In en, this message translates to: + /// **'Regional data'** + String get spoofRegionalSectionTitle; + + /// No description provided for @spoofFieldScreen. + /// + /// In en, this message translates to: + /// **'Screen resolution'** + String get spoofFieldScreen; + + /// No description provided for @spoofFieldTimezone. + /// + /// In en, this message translates to: + /// **'Timezone'** + String get spoofFieldTimezone; + + /// No description provided for @spoofFieldLocale. + /// + /// In en, this message translates to: + /// **'Locale'** + String get spoofFieldLocale; + + /// No description provided for @spoofIdentifiersSectionTitle. + /// + /// In en, this message translates to: + /// **'Identifiers'** + String get spoofIdentifiersSectionTitle; + + /// No description provided for @spoofIdentifiersDescription. + /// + /// In en, this message translates to: + /// **'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'** + String get spoofIdentifiersDescription; + + /// No description provided for @spoofFieldDeviceId. + /// + /// In en, this message translates to: + /// **'Device ID'** + String get spoofFieldDeviceId; + + /// No description provided for @spoofRegenerateIdTooltip. + /// + /// In en, this message translates to: + /// **'Generate a new ID'** + String get spoofRegenerateIdTooltip; + + /// No description provided for @spoofFieldAppVersion. + /// + /// In en, this message translates to: + /// **'App version'** + String get spoofFieldAppVersion; + + /// No description provided for @spoofFieldBuildNumber. + /// + /// In en, this message translates to: + /// **'Build number'** + String get spoofFieldBuildNumber; + + /// No description provided for @spoofFieldArchitecture. + /// + /// In en, this message translates to: + /// **'Architecture'** + String get spoofFieldArchitecture; + + /// No description provided for @spoofButtonGenerate. + /// + /// In en, this message translates to: + /// **'Generate'** + String get spoofButtonGenerate; + + /// No description provided for @spoofButtonApply. + /// + /// In en, this message translates to: + /// **'Apply'** + String get spoofButtonApply; + + /// No description provided for @spoofDialogUnsureTitle. + /// + /// In en, this message translates to: + /// **'Are you sure?'** + String get spoofDialogUnsureTitle; + + /// No description provided for @spoofDialogUnsureContent. + /// + /// In en, this message translates to: + /// **'The app may become unstable due to API incompatibility'** + String get spoofDialogUnsureContent; + + /// No description provided for @spoofDialogCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get spoofDialogCancel; + + /// No description provided for @spoofDialogYes. + /// + /// In en, this message translates to: + /// **'Yes'** + String get spoofDialogYes; + + /// No description provided for @spoofDialogApplyTitle. + /// + /// In en, this message translates to: + /// **'Apply settings?'** + String get spoofDialogApplyTitle; + + /// No description provided for @spoofDialogApplyContent. + /// + /// In en, this message translates to: + /// **'Need to reconnect the app, ok?'** + String get spoofDialogApplyContent; + + /// No description provided for @spoofDialogApplyWarning. + /// + /// In en, this message translates to: + /// **'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible'** + String get spoofDialogApplyWarning; + + /// No description provided for @spoofDialogReloginTitle. + /// + /// In en, this message translates to: + /// **'Done!'** + String get spoofDialogReloginTitle; + + /// No description provided for @spoofDialogReloginContent. + /// + /// In en, this message translates to: + /// **'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.'** + String get spoofDialogReloginContent; + + /// No description provided for @spoofDialogReloginWarning. + /// + /// In en, this message translates to: + /// **'Re-login now?'** + String get spoofDialogReloginWarning; + + /// No description provided for @spoofDialogReloginDeny. + /// + /// In en, this message translates to: + /// **'Later'** + String get spoofDialogReloginDeny; + + /// No description provided for @spoofDialogReloginConfirm. + /// + /// In en, this message translates to: + /// **'Re-login now'** + String get spoofDialogReloginConfirm; + + /// No description provided for @spoofDialogApplyDeny. + /// + /// In en, this message translates to: + /// **'No'** + String get spoofDialogApplyDeny; + + /// No description provided for @spoofDialogApplyConfirm. + /// + /// In en, this message translates to: + /// **'Ok!'** + String get spoofDialogApplyConfirm; + + /// No description provided for @spoofErrorApplyFailed. + /// + /// In en, this message translates to: + /// **'Failed to apply settings: {error}'** + String spoofErrorApplyFailed(String error); + + /// No description provided for @profileMenuSpoof. + /// + /// In en, this message translates to: + /// **'Spoofing'** + String get profileMenuSpoof; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['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.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..bfce511 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,292 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get loginTitle => 'Sign in to Komet'; + + @override + String get loginSubtitle => + 'Check your country code and enter your\nphone number.'; + + @override + String get loginCountry => 'Country'; + + @override + String get loginPhoneNumber => 'Phone number'; + + @override + String get loginOtherSignInMethods => 'Other sign-in methods'; + + @override + String get loginTermsIntro => 'By continuing, you agree to \n'; + + @override + String get loginTermsLink => 'the terms of use'; + + @override + String get loginTermsOfUse => 'Terms of use'; + + @override + String get loginConfirmPhoneTitle => 'Is this the correct number?'; + + @override + String get loginEdit => 'Change'; + + @override + String get loginDone => 'Done'; + + @override + String get loginReadTermsNotification => 'Please read the terms of use first'; + + @override + String get loginSpoofRedacted => 'Spoofing'; + + @override + String get loginProxy => 'Proxy'; + + @override + String get loginChangeServer => 'Change server'; + + @override + String get serverSettingsTitle => 'Server'; + + @override + String get serverHostLabel => 'Host'; + + @override + String get serverPortLabel => 'Port'; + + @override + String get serverApply => 'Apply and reconnect'; + + @override + String get serverUseDefault => 'Reset to default'; + + @override + String get serverInvalidHostOrPort => 'Enter a valid host and port (1–65535)'; + + @override + String get serverSettingsSaved => 'Server settings applied'; + + @override + String get serverReconnectFailed => 'Could not connect to the server'; + + @override + String get loginSignInWithQr => 'Sign in with QR code'; + + @override + String get loginSignInWithToken => 'Sign in with token'; + + @override + String get loginSignInWithSessionFile => 'Sign in with session file'; + + @override + String get loginLanguage => 'Language'; + + @override + String get languageNameRu => 'Русский'; + + @override + String get languageNameEn => 'English'; + + @override + String get selectCountryTitle => 'Select country'; + + @override + String get selectCountrySearchHint => 'Search countries…'; + + @override + String get codeConfirmationSmsSent => + 'We sent an SMS with a verification code to your phone number.'; + + @override + String codeResendInSeconds(int seconds) { + return 'Resend in $seconds s.'; + } + + @override + String get codeResendSms => 'Resend code via SMS'; + + @override + String get codeError2faMissing => 'Error: missing data for 2FA'; + + @override + String get proxySettingsTitle => 'Proxy'; + + @override + String get proxyTypeNone => 'Disabled'; + + @override + String get proxyTypeSocks5 => 'SOCKS5'; + + @override + String get proxyTypeHttp => 'HTTP(S)'; + + @override + String get proxyHostLabel => 'Proxy host'; + + @override + String get proxyPortLabel => 'Proxy port'; + + @override + String get proxyUsernameLabel => 'Username (optional)'; + + @override + String get proxyPasswordLabel => 'Password (optional)'; + + @override + String get proxyApply => 'Apply and reconnect'; + + @override + String get proxyDisable => 'Disable proxy'; + + @override + String get proxySettingsSaved => 'Proxy settings applied'; + + @override + String get proxyInvalidHostOrPort => + 'Enter a valid proxy host and port (1–65535)'; + + @override + String get spoofScreenTitle => 'Session spoofing'; + + @override + String get spoofInfoHint => + 'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.'; + + @override + String get spoofMethodTitle => 'Spoofing method'; + + @override + String get spoofMethodPartial => 'Partial'; + + @override + String get spoofMethodFull => 'Full'; + + @override + String get spoofMethodPartialDescription => + 'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.'; + + @override + String get spoofMethodFullDescription => + 'All data including timezone and locale is generated randomly. Use this method at your own risk!'; + + @override + String get spoofDeviceTypeTitle => 'Device type'; + + @override + String get spoofDeviceTypeDescription => + 'This field is not changeable, to avoid token association issues'; + + @override + String get spoofDeviceTypeLabel => 'Device type'; + + @override + String get spoofMainSectionTitle => 'Main data'; + + @override + String get spoofFieldDeviceName => 'Device name'; + + @override + String get spoofFieldOsVersion => 'OS version'; + + @override + String get spoofRegionalSectionTitle => 'Regional data'; + + @override + String get spoofFieldScreen => 'Screen resolution'; + + @override + String get spoofFieldTimezone => 'Timezone'; + + @override + String get spoofFieldLocale => 'Locale'; + + @override + String get spoofIdentifiersSectionTitle => 'Identifiers'; + + @override + String get spoofIdentifiersDescription => + 'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'; + + @override + String get spoofFieldDeviceId => 'Device ID'; + + @override + String get spoofRegenerateIdTooltip => 'Generate a new ID'; + + @override + String get spoofFieldAppVersion => 'App version'; + + @override + String get spoofFieldBuildNumber => 'Build number'; + + @override + String get spoofFieldArchitecture => 'Architecture'; + + @override + String get spoofButtonGenerate => 'Generate'; + + @override + String get spoofButtonApply => 'Apply'; + + @override + String get spoofDialogUnsureTitle => 'Are you sure?'; + + @override + String get spoofDialogUnsureContent => + 'The app may become unstable due to API incompatibility'; + + @override + String get spoofDialogCancel => 'Cancel'; + + @override + String get spoofDialogYes => 'Yes'; + + @override + String get spoofDialogApplyTitle => 'Apply settings?'; + + @override + String get spoofDialogApplyContent => 'Need to reconnect the app, ok?'; + + @override + String get spoofDialogApplyWarning => + 'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible'; + + @override + String get spoofDialogReloginTitle => 'Done!'; + + @override + String get spoofDialogReloginContent => + 'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.'; + + @override + String get spoofDialogReloginWarning => 'Re-login now?'; + + @override + String get spoofDialogReloginDeny => 'Later'; + + @override + String get spoofDialogReloginConfirm => 'Re-login now'; + + @override + String get spoofDialogApplyDeny => 'No'; + + @override + String get spoofDialogApplyConfirm => 'Ok!'; + + @override + String spoofErrorApplyFailed(String error) { + return 'Failed to apply settings: $error'; + } + + @override + String get profileMenuSpoof => 'Spoofing'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 0000000..7a1c8b4 --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Подмена данных'; +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..0f2378c --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -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": "Подмена данных" +} diff --git a/lib/l10n/terms_of_service.dart b/lib/l10n/terms_of_service.dart new file mode 100644 index 0000000..ec8c52a --- /dev/null +++ b/lib/l10n/terms_of_service.dart @@ -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; +} diff --git a/lib/l10n/tos_en.dart b/lib/l10n/tos_en.dart new file mode 100644 index 0000000..45a3f16 --- /dev/null +++ b/lib/l10n/tos_en.dart @@ -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 +'''; diff --git a/lib/l10n/tos_ru.dart b/lib/l10n/tos_ru.dart new file mode 100644 index 0000000..7354d08 --- /dev/null +++ b/lib/l10n/tos_ru.dart @@ -0,0 +1,101 @@ +const String kTermsOfServiceRu = r''' +Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet" + +1. Статус и отношения +1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX"). +1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа». +1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям. + +2. Условия использования +2.1. Используя Приложение «Komet», вы: +• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps) +• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»; +• Принимаете на себя все риски, связанные с использованием Приложения. +2.2. Строго запрещено: +• Использовать Приложение «Komet» для распространения запрещённого контента; +• Осуществлять массовые рассылки (спам); +• Нарушать законодательство РФ и международное право; +• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX». +2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ. +2.4. Особенности технического взаимодействия: +• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru) +• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости; +• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО. + +3. Технические аспекты +3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки. +3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости. +3.3. Разработчики не несут ответственности за: +• Изменения в API оригинального сервиса; +• Блокировку аккаунтов пользователей; +• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа». + +4. Конфиденциальность +4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей. +4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа». +4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей. + +5. Ответственность и ограничения +5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности. +5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин. + +6. Правовые основания +6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с: +• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости; +• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях; +• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации; +• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ. +6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через: +• Публичные API‑интерфейсы, доступные через веб‑версию сервиса; +• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости; +• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны. +6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ. + +7. Заключительные положения +7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что: +• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов; +• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»; +• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки. +7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей. + +8. Функции безопасности и конфиденциальности +8.1. Приложение «Komet» включает инструменты защиты приватности: +• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT); +• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия; +• Ограничение телеметрии — для минимизации передачи диагностических данных. +8.2. Данные функции: +• Направлены исключительно на защиту конфиденциальности пользователей; +• Не используются для обхода систем безопасности оригинального сервиса; +• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни. +8.3. Разработчики не несут ответственности за: +• Блокировки, связанные с использованием инструментов конфиденциальности; +• Изменения в работе сервиса при активации данных функций. +8.4. Функции экспорта и импорта сессии +8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для: +• Обеспечения переносимости данных между устройствами пользователя +• Резервного копирования учетных данных +• Восстановления доступа при утере устройства +8.4.2. Особенности реализации: +• Экспорт сессии осуществляется без привязки к номеру телефона +• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256 +• Ключ шифрования известен только пользователю и не сохраняется в приложении +8.4.3. Техническая реализация экспорта сессии: +• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе +• Используется подмена параметров сессии для сохранения контекста аутентификации +• Интеграция настроек прокси для обеспечения единой конфигурации подключения +• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки +• Серверы оригинального сервиса не получают данных о смене устройства пользователя +• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг) +8.4.4. Правовые основания: +• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта +• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных) +• Принцип минимизации данных — сбор только необходимой для работы информации +• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается) +• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies) +• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152) +8.4.5. Ограничения ответственности: +• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий +• Разработчики не имеют доступа к зашифрованным данным сессии +• Восстановление утерянных паролей невозможно в целях безопасности +• Ключи шифрования не хранятся в приложении и известны только пользователю +'''; diff --git a/lib/main.dart b/lib/main.dart index a50de9b..0ce4998 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _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(); + + static KometAppState? stateOf(BuildContext context) { + return context.findAncestorStateOfType(); + } + + @override + State createState() => KometAppState(); +} + +class KometAppState extends State { static const _fallbackSeed = Color(0xFFC1C4FF); - static ColorScheme _adjustScheme(ColorScheme base) { + late Locale _locale; + bool _isLoggingOut = false; + late final ValueNotifier 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 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 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( + 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 _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), + ), + ); + } +} diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart new file mode 100644 index 0000000..2d3b6b1 --- /dev/null +++ b/lib/models/attachment.dart @@ -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 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 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 map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.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 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 map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.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 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 map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.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 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 map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.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 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 map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.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 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 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 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 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 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? userIds; + + const ControlAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.event, + this.title, + this.userIds, + }) : super(type: AttachmentType.control); + + factory ControlAttachment.fromMap(Map 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(), + ); + } + + @override + Map 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? 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 map) { + final linkRaw = map['link']; + Map? link; + if (linkRaw is Map) { + link = Map.from(linkRaw); + } + + Map? message; + if (link != null) { + final msgRaw = link['message']; + if (msgRaw is Map) { + message = Map.from(msgRaw); + } + } + + List? originalAttaches; + ContactAttachment? originalContact; + if (message != null) { + final attaches = message['attaches'] as List?; + if (attaches != null) { + final contactAttaches = attaches + .whereType() + .where((a) => (a['_type'] as String?)?.toUpperCase() == 'CONTACT') + .toList(); + if (contactAttaches.isNotEmpty) { + originalContact = ContactAttachment.fromMap( + Map.from(contactAttaches.first), + ); + } + originalAttaches = attaches + .whereType() + .where((a) { + final type = (a['_type'] as String?)?.toUpperCase(); + return type != 'CONTACT'; + }) + .map((a) => MessageAttachment.fromMap(Map.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 toMap() => { + '_type': 'FORWARD', + 'originalSenderId': originalSenderId, + 'originalSenderName': originalSenderName, + 'originalSenderAvatar': originalSenderAvatar, + 'originalMessageId': originalMessageId, + 'originalTime': originalTime, + 'originalText': originalText, + 'originalChatId': originalChatId, + }; +} + +class UnknownAttachment extends MessageAttachment { + final Map rawData; + + const UnknownAttachment(this.rawData) : super(type: AttachmentType.photo); + + @override + Map toMap() => rawData; +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..14765ba --- /dev/null +++ b/macos/Podfile.lock @@ -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 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 2110d8f..7188c51 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 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 = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* 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 = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -77,7 +81,13 @@ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; /* 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 = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -125,6 +151,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 1DB0FC36071C177710C75E9D /* Pods */, ); sourceTree = ""; }; @@ -175,6 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */, + BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -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; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pubspec.lock b/pubspec.lock index 8948ca6..1f7d572 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 244e464..f681c96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart index b921755..b382eb1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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); }); }