v0.4.0: Merge pull request #1 from KometTeam/reorg

v0.4.0: Codebase reorganization, tons of small fixes, registration and workable context menus on Desktop
This commit is contained in:
nyakokitsu
2025-11-30 15:42:26 +03:00
committed by GitHub
67 changed files with 3133 additions and 1685 deletions

4
.gitignore vendored
View File

@@ -43,3 +43,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
pubspec.lock
.vscode/
macos/Podfile.lock

View File

@@ -1,3 +0,0 @@
{
"cmake.sourceDirectory": "C:/Users/Kiril/OneDrive/Desktop/KOMET/KOMETABLYATSKAYA/app/linux"
}

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@@ -0,0 +1,753 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:es_compression/lz4.dart';
import 'package:ffi/ffi.dart';
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
// FFI типы для LZ4 block decompress
typedef Lz4DecompressFunction =
Int32 Function(
Pointer<Uint8> src,
Pointer<Uint8> dst,
Int32 compressedSize,
Int32 dstCapacity,
);
typedef Lz4Decompress =
int Function(
Pointer<Uint8> src,
Pointer<Uint8> dst,
int compressedSize,
int dstCapacity,
);
class RegistrationService {
Socket? _socket;
int _seq = 0;
final Map<int, Completer<dynamic>> _pending = {};
bool _isConnected = false;
Timer? _pingTimer;
StreamSubscription? _socketSubscription;
Lz4Codec? _lz4Codec;
DynamicLibrary? _lz4Lib;
Lz4Decompress? _lz4BlockDecompress;
void _initLz4BlockDecompress() {
if (_lz4BlockDecompress != null) return;
try {
if (Platform.isWindows) {
// Пробуем загрузить eslz4-win64.dll
final dllPath = 'eslz4-win64.dll';
print('📦 Загрузка LZ4 DLL для block decompress: $dllPath');
_lz4Lib = DynamicLibrary.open(dllPath);
// Ищем функцию LZ4_decompress_safe (block format)
try {
_lz4BlockDecompress = _lz4Lib!
.lookup<NativeFunction<Lz4DecompressFunction>>(
'LZ4_decompress_safe',
)
.asFunction();
print('✅ LZ4 block decompress функция загружена');
} catch (e) {
print(
'⚠️ Функция LZ4_decompress_safe не найдена, пробуем альтернативные имена...',
);
// Пробуем другие возможные имена
try {
_lz4BlockDecompress = _lz4Lib!
.lookup<NativeFunction<Lz4DecompressFunction>>(
'LZ4_decompress_fast',
)
.asFunction();
print('✅ LZ4 block decompress функция загружена (fast)');
} catch (e2) {
print('Не удалось найти LZ4 block decompress функцию: $e2');
}
}
}
} catch (e) {
print('⚠️ Не удалось загрузить LZ4 DLL для block decompress: $e');
print('📦 Будем использовать только frame format (es_compression)');
}
}
Future<void> connect() async {
if (_isConnected) return;
// Инициализируем LZ4 block decompress
_initLz4BlockDecompress();
try {
print('🌐 Подключаемся к api.oneme.ru:443...');
// Создаем SSL контекст
final securityContext = SecurityContext.defaultContext;
print('🔒 Создаем TCP соединение...');
final rawSocket = await Socket.connect('api.oneme.ru', 443);
print('✅ TCP соединение установлено');
print('🔒 Устанавливаем SSL соединение...');
_socket = await SecureSocket.secure(
rawSocket,
context: securityContext,
host: 'api.oneme.ru',
onBadCertificate: (certificate) {
print('⚠️ Сертификат не прошел проверку, принимаем...');
return true;
},
);
_isConnected = true;
print('✅ SSL соединение установлено');
// Запускаем ping loop
_startPingLoop();
// Слушаем ответы
_socketSubscription = _socket!.listen(
_handleData,
onError: (error) {
print('❌ Ошибка сокета: $error');
_isConnected = false;
},
onDone: () {
print('🔌 Соединение закрыто');
_isConnected = false;
},
);
} catch (e) {
print('❌ Ошибка подключения: $e');
rethrow;
}
}
void _startPingLoop() {
_pingTimer?.cancel();
_pingTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
if (!_isConnected) {
timer.cancel();
return;
}
try {
await _sendMessage(1, {});
print('🏓 Ping отправлен');
} catch (e) {
print('❌ Ping failed: $e');
}
});
}
void _handleData(Uint8List data) {
// Обрабатываем данные по частям - сначала заголовок, потом payload
_processIncomingData(data);
}
Uint8List? _buffer = Uint8List(0);
void _processIncomingData(Uint8List newData) {
// Добавляем новые данные в буфер
_buffer = Uint8List.fromList([..._buffer!, ...newData]);
while (_buffer!.length >= 10) {
// Читаем заголовок
final header = _buffer!.sublist(0, 10);
final payloadLen =
ByteData.view(header.buffer, 6, 4).getUint32(0, Endian.big) &
0xFFFFFF;
if (_buffer!.length < 10 + payloadLen) {
// Недостаточно данных, ждем еще
break;
}
// Полный пакет готов
final fullPacket = _buffer!.sublist(0, 10 + payloadLen);
_buffer = _buffer!.sublist(10 + payloadLen);
_processPacket(fullPacket);
}
}
void _processPacket(Uint8List packet) {
try {
// Разбираем заголовок
final ver = packet[0];
final cmd = ByteData.view(packet.buffer).getUint16(1, Endian.big);
final seq = packet[3];
final opcode = ByteData.view(packet.buffer).getUint16(4, Endian.big);
final packedLen = ByteData.view(
packet.buffer,
6,
4,
).getUint32(0, Endian.big);
// Проверяем флаг сжатия (как в packet_framer.dart)
final compFlag = packedLen >> 24;
final payloadLen = packedLen & 0x00FFFFFF;
print('═══════════════════════════════════════════════════════════');
print('📥 ПОЛУЧЕН ПАКЕТ ОТ СЕРВЕРА');
print('═══════════════════════════════════════════════════════════');
print(
'📋 Заголовок: ver=$ver, cmd=$cmd, seq=$seq, opcode=$opcode, packedLen=$packedLen, compFlag=$compFlag, payloadLen=$payloadLen',
);
print('📦 Полный пакет (hex, ${packet.length} байт):');
print(_bytesToHex(packet));
print('');
final payloadBytes = packet.sublist(10, 10 + payloadLen);
print('📦 Сырые payload байты (hex, ${payloadBytes.length} байт):');
print(_bytesToHex(payloadBytes));
print('');
final payload = _unpackPacketPayload(payloadBytes, compFlag != 0);
print('📦 Разобранный payload (после LZ4 и msgpack):');
print(_formatPayload(payload));
print('═══════════════════════════════════════════════════════════');
print('');
// Находим completer по seq
final completer = _pending[seq];
if (completer != null && !completer.isCompleted) {
completer.complete(payload);
print('✅ Completer завершен для seq=$seq');
} else {
print('⚠️ Completer не найден для seq=$seq');
}
} catch (e) {
print('❌ Ошибка разбора пакета: $e');
print('Stack trace: ${StackTrace.current}');
}
}
Uint8List _packPacket(
int ver,
int cmd,
int seq,
int opcode,
Map<String, dynamic> payload,
) {
final verB = Uint8List(1)..[0] = ver;
final cmdB = Uint8List(2)
..buffer.asByteData().setUint16(0, cmd, Endian.big);
final seqB = Uint8List(1)..[0] = seq;
final opcodeB = Uint8List(2)
..buffer.asByteData().setUint16(0, opcode, Endian.big);
final payloadBytes = msgpack.serialize(payload);
final payloadLen = payloadBytes.length & 0xFFFFFF;
final payloadLenB = Uint8List(4)
..buffer.asByteData().setUint32(0, payloadLen, Endian.big);
final packet = Uint8List.fromList(
verB + cmdB + seqB + opcodeB + payloadLenB + payloadBytes,
);
print('═══════════════════════════════════════════════════════════');
print('📤 ОТПРАВЛЯЕМ ПАКЕТ НА СЕРВЕР');
print('═══════════════════════════════════════════════════════════');
print(
'📋 Заголовок: ver=$ver, cmd=$cmd, seq=$seq, opcode=$opcode, payloadLen=$payloadLen',
);
print('📦 Payload (JSON):');
print(_formatPayload(payload));
print('📦 Payload (msgpack hex, ${payloadBytes.length} байт):');
print(_bytesToHex(payloadBytes));
print('📦 Полный пакет (hex, ${packet.length} байт):');
print(_bytesToHex(packet));
print('═══════════════════════════════════════════════════════════');
print('');
return packet;
}
String _bytesToHex(Uint8List bytes) {
final buffer = StringBuffer();
for (int i = 0; i < bytes.length; i++) {
if (i > 0 && i % 16 == 0) buffer.writeln();
buffer.write(bytes[i].toRadixString(16).padLeft(2, '0').toUpperCase());
buffer.write(' ');
}
return buffer.toString();
}
String _formatPayload(dynamic payload) {
if (payload == null) return 'null';
if (payload is Map) {
final buffer = StringBuffer();
_formatMap(payload, buffer, 0);
return buffer.toString();
}
return payload.toString();
}
void _formatMap(Map map, StringBuffer buffer, int indent) {
final indentStr = ' ' * indent;
buffer.writeln('{');
map.forEach((key, value) {
buffer.write('$indentStr "$key": ');
if (value is Map) {
_formatMap(value, buffer, indent + 1);
} else if (value is List) {
buffer.writeln('[');
for (var item in value) {
buffer.write('$indentStr ');
if (item is Map) {
_formatMap(item, buffer, indent + 2);
} else {
buffer.writeln('$item,');
}
}
buffer.writeln('$indentStr ],');
} else {
buffer.writeln('$value,');
}
});
buffer.write('$indentStr}');
if (indent > 0) buffer.writeln(',');
}
dynamic _deserializeMsgpack(Uint8List data) {
print('📦 Десериализация msgpack...');
try {
final payload = msgpack.deserialize(data);
print('✅ Msgpack десериализация успешна');
// Проверяем, что получили валидный результат (не просто число)
if (payload is int && payload < 0) {
print(
'⚠️ Получено отрицательное число вместо Map - возможно данные все еще сжаты',
);
return null;
}
return payload;
} catch (e) {
print('❌ Ошибка десериализации msgpack: $e');
return null;
}
}
dynamic _unpackPacketPayload(
Uint8List payloadBytes, [
bool isCompressed = false,
]) {
if (payloadBytes.isEmpty) {
print('📦 Payload пустой');
return null;
}
try {
// Сначала пробуем LZ4 декомпрессию как в register.py
Uint8List decompressedBytes = payloadBytes;
// Если данные сжаты (compFlag != 0), пробуем LZ4 block декомпрессию
if (isCompressed && payloadBytes.length > 4) {
print('📦 Данные помечены как сжатые (compFlag != 0)');
// Пробуем LZ4 block декомпрессию через FFI (как в register.py)
try {
if (_lz4BlockDecompress != null) {
print('📦 Попытка LZ4 block декомпрессии через FFI...');
// В register.py используется фиксированный uncompressed_size=99999
// И данные используются полностью (без пропуска первых 4 байт)
// Но в packet_framer.dart при compFlag пропускаются первые 4 байта
// Попробуем оба варианта
// Вариант 1: как в register.py - используем все данные с фиксированным размером
// Увеличиваем размер для больших ответов (как в register.py используется 99999, но может быть недостаточно)
int uncompressedSize =
500000; // Увеличенный размер для больших ответов
Uint8List compressedData = payloadBytes;
print(
'📦 Попытка 1: Используем все данные с uncompressed_size=99999 (как в register.py)',
);
try {
if (uncompressedSize > 0 && uncompressedSize < 10 * 1024 * 1024) {
final srcSize = compressedData.length;
final srcPtr = malloc.allocate<Uint8>(srcSize);
final dstPtr = malloc.allocate<Uint8>(uncompressedSize);
try {
final srcList = srcPtr.asTypedList(srcSize);
srcList.setAll(0, compressedData);
final result = _lz4BlockDecompress!(
srcPtr,
dstPtr,
srcSize,
uncompressedSize,
);
if (result > 0) {
final actualSize = result;
final dstList = dstPtr.asTypedList(actualSize);
decompressedBytes = Uint8List.fromList(dstList);
print(
'✅ LZ4 block декомпрессия успешна: $srcSize${decompressedBytes.length} байт',
);
print(
'📦 Декомпрессированные данные (hex, первые 64 байта):',
);
final preview = decompressedBytes.length > 64
? decompressedBytes.sublist(0, 64)
: decompressedBytes;
print(_bytesToHex(preview));
// Успешная декомпрессия - возвращаем результат
return _deserializeMsgpack(decompressedBytes);
} else {
throw Exception('LZ4 декомпрессия вернула ошибку: $result');
}
} finally {
malloc.free(srcPtr);
malloc.free(dstPtr);
}
}
} catch (e1) {
print('⚠️ Вариант 1 не сработал: $e1');
// Вариант 2: пропускаем первые 4 байта (как в packet_framer.dart)
if (payloadBytes.length > 4) {
print('📦 Попытка 2: Пропускаем первые 4 байта...');
compressedData = payloadBytes.sublist(4);
print('📦 Сжатые данные (hex, первые 32 байта):');
final firstBytes = compressedData.length > 32
? compressedData.sublist(0, 32)
: compressedData;
print(_bytesToHex(firstBytes));
try {
final srcSize = compressedData.length;
final srcPtr = malloc.allocate<Uint8>(srcSize);
final dstPtr = malloc.allocate<Uint8>(uncompressedSize);
try {
final srcList = srcPtr.asTypedList(srcSize);
srcList.setAll(0, compressedData);
final result = _lz4BlockDecompress!(
srcPtr,
dstPtr,
srcSize,
uncompressedSize,
);
if (result > 0) {
final actualSize = result;
final dstList = dstPtr.asTypedList(actualSize);
decompressedBytes = Uint8List.fromList(dstList);
print(
'✅ LZ4 block декомпрессия успешна (вариант 2): $srcSize${decompressedBytes.length} байт',
);
print(
'📦 Декомпрессированные данные (hex, первые 64 байта):',
);
final preview = decompressedBytes.length > 64
? decompressedBytes.sublist(0, 64)
: decompressedBytes;
print(_bytesToHex(preview));
// Успешная декомпрессия - возвращаем результат
return _deserializeMsgpack(decompressedBytes);
} else {
throw Exception(
'LZ4 декомпрессия вернула ошибку: $result',
);
}
} finally {
malloc.free(srcPtr);
malloc.free(dstPtr);
}
} catch (e2) {
print('⚠️ Вариант 2 не сработал: $e2');
throw e2; // Пробрасываем ошибку дальше
}
} else {
throw e1;
}
}
} else {
// Пробуем через es_compression (frame format)
final compressedData = payloadBytes.sublist(4);
if (_lz4Codec == null) {
print('📦 Инициализация Lz4Codec (frame format)...');
_lz4Codec = Lz4Codec();
print('✅ Lz4Codec инициализирован успешно');
}
print('📦 Попытка декомпрессии через es_compression...');
final decoded = _lz4Codec!.decode(compressedData);
decompressedBytes = decoded is Uint8List
? decoded
: Uint8List.fromList(decoded);
print(
'✅ LZ4 декомпрессия успешна: ${compressedData.length}${decompressedBytes.length} байт',
);
}
} catch (lz4Error) {
print('⚠️ LZ4 декомпрессия не применена: $lz4Error');
print('📦 Тип ошибки: ${lz4Error.runtimeType}');
print('📦 Используем сырые данные...');
decompressedBytes = payloadBytes;
}
} else {
// Данные не сжаты или нет флага - пробуем LZ4 на всякий случай (как в register.py)
print(
'📦 Данные не помечены как сжатые, но пробуем LZ4 (как в register.py)...',
);
final firstBytes = payloadBytes.length > 32
? payloadBytes.sublist(0, 32)
: payloadBytes;
print(
'📦 Первые ${firstBytes.length} байта payload (hex): ${_bytesToHex(firstBytes)}',
);
try {
if (_lz4Codec == null) {
print('📦 Инициализация Lz4Codec...');
_lz4Codec = Lz4Codec();
print('✅ Lz4Codec инициализирован успешно');
}
print('📦 Попытка декомпрессии ${payloadBytes.length} байт...');
final decoded = _lz4Codec!.decode(payloadBytes);
decompressedBytes = decoded is Uint8List
? decoded
: Uint8List.fromList(decoded);
print(
'✅ LZ4 декомпрессия успешна: ${payloadBytes.length}${decompressedBytes.length} байт',
);
} catch (lz4Error) {
// Если LZ4 не удалась (данные не сжаты), используем сырые данные
print(
'⚠️ LZ4 декомпрессия не применена (данные не сжаты): $lz4Error',
);
decompressedBytes = payloadBytes;
}
}
return _deserializeMsgpack(decompressedBytes);
} catch (e) {
print('❌ Ошибка десериализации payload: $e');
print('Stack trace: ${StackTrace.current}');
return null;
}
}
Future<dynamic> _sendMessage(int opcode, Map<String, dynamic> payload) async {
if (!_isConnected || _socket == null) {
throw Exception('Не подключено к серверу');
}
_seq = (_seq + 1) % 256;
final seq = _seq;
final packet = _packPacket(10, 0, seq, opcode, payload);
print('📤 Отправляем сообщение opcode=$opcode, seq=$seq');
final completer = Completer<dynamic>();
_pending[seq] = completer;
_socket!.add(packet);
await _socket!.flush();
return completer.future.timeout(const Duration(seconds: 30));
}
Future<String> startRegistration(String phoneNumber) async {
await connect();
// Отправляем handshake
final handshakePayload = {
"mt_instanceid": "63ae21a8-2417-484d-849b-0ae464a7b352",
"userAgent": {
"deviceType": "ANDROID",
"appVersion": "25.14.2",
"osVersion": "Android 14",
"timezone": "Europe/Moscow",
"screen": "440dpi 440dpi 1080x2072",
"pushDeviceType": "GCM",
"arch": "x86_64",
"locale": "ru",
"buildNumber": 6442,
"deviceName": "unknown Android SDK built for x86_64",
"deviceLocale": "en",
},
"clientSessionId": 8,
"deviceId": "d53058ab998c3bdd",
};
print('🤝 Отправляем handshake (opcode=6)...');
print('📦 Handshake payload:');
print(_formatPayload(handshakePayload));
final handshakeResponse = await _sendMessage(6, handshakePayload);
print('📨 Ответ от handshake:');
print(_formatPayload(handshakeResponse));
// Проверяем ошибки
if (handshakeResponse is Map) {
final err = handshakeResponse['payload']?['error'];
if (err != null) {
print('❌ Ошибка handshake: $err');
}
}
// Отправляем START_AUTH
final authPayload = {"type": "START_AUTH", "phone": phoneNumber};
print('🚀 Отправляем START_AUTH (opcode=17)...');
print('📦 START_AUTH payload:');
print(_formatPayload(authPayload));
final response = await _sendMessage(17, authPayload);
print('📨 Ответ от START_AUTH:');
print(_formatPayload(response));
// Проверяем ошибки
if (response is Map) {
// Проверяем ошибку в payload или в корне ответа
final payload = response['payload'] ?? response;
final err = payload['error'] ?? response['error'];
if (err != null) {
// Обрабатываем конкретную ошибку limit.violate
if (err.toString().contains('limit.violate') ||
err.toString().contains('error.limit.violate')) {
throw Exception(
'У вас кончились попытки на код, попробуйте позже...',
);
}
// Для других ошибок используем сообщение от сервера или общее
final message =
payload['localizedMessage'] ??
payload['message'] ??
payload['description'] ??
'Ошибка START_AUTH: $err';
throw Exception(message);
}
}
// Извлекаем токен из ответа (как в register.py)
if (response is Map) {
final payload = response['payload'] ?? response;
final token = payload['token'] ?? response['token'];
if (token != null) {
return token as String;
}
}
throw Exception('Не удалось получить токен из ответа сервера');
}
Future<String> verifyCode(String token, String code) async {
final verifyPayload = {
"verifyCode": code,
"token": token,
"authTokenType": "CHECK_CODE",
};
print('🔍 Проверяем код (opcode=18)...');
print('📦 CHECK_CODE payload:');
print(_formatPayload(verifyPayload));
final response = await _sendMessage(18, verifyPayload);
print('📨 Ответ от CHECK_CODE:');
print(_formatPayload(response));
// Проверяем ошибки
if (response is Map) {
// Проверяем ошибку в payload или в корне ответа
final payload = response['payload'] ?? response;
final err = payload['error'] ?? response['error'];
if (err != null) {
// Обрабатываем конкретную ошибку неправильного кода
if (err.toString().contains('verify.code.wrong') ||
err.toString().contains('wrong.code') ||
err.toString().contains('code.wrong')) {
throw Exception('Неверный код');
}
// Для других ошибок используем сообщение от сервера или общее
final message =
payload['localizedMessage'] ??
payload['message'] ??
payload['title'] ??
'Ошибка CHECK_CODE: $err';
throw Exception(message);
}
}
// Извлекаем register токен (как в register.py)
if (response is Map) {
final tokenSrc = response['payload'] ?? response;
final tokenAttrs = tokenSrc['tokenAttrs'];
// Проверяем, есть ли LOGIN токен - значит аккаунт уже существует
if (tokenAttrs is Map && tokenAttrs['LOGIN'] is Map) {
throw Exception('ACCOUNT_EXISTS');
}
if (tokenAttrs is Map && tokenAttrs['REGISTER'] is Map) {
final registerToken = tokenAttrs['REGISTER']['token'];
if (registerToken != null) {
return registerToken as String;
}
}
}
throw Exception('Не удалось получить токен регистрации из ответа сервера');
}
Future<void> completeRegistration(String registerToken) async {
final registerPayload = {
"lastName": "User",
"token": registerToken,
"firstName": "Komet",
"tokenType": "REGISTER",
};
print('🎉 Завершаем регистрацию (opcode=23)...');
print('📦 REGISTER payload:');
print(_formatPayload(registerPayload));
final response = await _sendMessage(23, registerPayload);
print('📨 Ответ от REGISTER:');
print(_formatPayload(response));
// Проверяем ошибки
if (response is Map) {
final err = response['payload']?['error'];
if (err != null) {
throw Exception('Ошибка REGISTER: $err');
}
// Извлекаем финальный токен
final payload = response['payload'] ?? response;
final finalToken = payload['token'] ?? response['token'];
if (finalToken != null) {
print('✅ Регистрация успешна, финальный токен: $finalToken');
return;
}
}
throw Exception('Регистрация не удалась');
}
void disconnect() {
try {
_isConnected = false;
_pingTimer?.cancel();
_socketSubscription?.cancel();
_socket?.close();
print('🔌 Отключено от сервера');
} catch (e) {
print('❌ Ошибка отключения: $e');
}
}
}

View File

@@ -8,18 +8,18 @@ import 'package:flutter/services.dart';
import 'package:gwid/connection/connection_logger.dart';
import 'package:gwid/connection/connection_state.dart' as conn_state;
import 'package:gwid/connection/health_monitor.dart';
import 'package:gwid/image_cache_service.dart';
import 'package:gwid/utils/image_cache_service.dart';
import 'package:gwid/models/complaint.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/models/message.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/utils/proxy_service.dart';
import 'package:gwid/services/account_manager.dart';
import 'package:gwid/services/avatar_cache_service.dart';
import 'package:gwid/services/cache_service.dart';
import 'package:gwid/services/chat_cache_service.dart';
import 'package:gwid/services/profile_cache_service.dart';
import 'package:gwid/spoofing_service.dart';
import 'package:gwid/utils/spoofing_service.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -255,4 +255,113 @@ extension ApiServiceAuth on ApiService {
rethrow;
}
}
// Registration methods
Future<String> startRegistration(String phoneNumber) async {
if (_channel == null) {
print('WebSocket не подключен, подключаемся...');
try {
await connect();
await waitUntilOnline();
} catch (e) {
print('Ошибка подключения к WebSocket: $e');
throw Exception('Не удалось подключиться к серверу: $e');
}
}
final payload = {
"phone": phoneNumber,
"type": "START_AUTH",
"language": "ru",
};
// Listen for the response
final completer = Completer<Map<String, dynamic>>();
final subscription = messages.listen((message) {
if (message['opcode'] == 17 && !completer.isCompleted) {
completer.complete(message);
}
});
_sendMessage(17, payload);
try {
final response = await completer.future.timeout(const Duration(seconds: 30));
subscription.cancel();
final payload = response['payload'];
if (payload != null && payload['token'] != null) {
return payload['token'];
} else {
throw Exception('No registration token received');
}
} catch (e) {
subscription.cancel();
rethrow;
}
}
Future<String> verifyRegistrationCode(String token, String code) async {
final payload = {
'token': token,
'verifyCode': code,
'authTokenType': 'CHECK_CODE',
};
final completer = Completer<Map<String, dynamic>>();
final subscription = messages.listen((message) {
if (message['opcode'] == 18 && !completer.isCompleted) {
completer.complete(message);
}
});
_sendMessage(18, payload);
try {
final response = await completer.future.timeout(const Duration(seconds: 30));
subscription.cancel();
final payload = response['payload'];
if (payload != null) {
final tokenAttrs = payload['tokenAttrs'];
if (tokenAttrs != null && tokenAttrs['REGISTER'] != null) {
final regToken = tokenAttrs['REGISTER']['token'];
if (regToken != null) {
return regToken;
}
}
}
throw Exception('Registration token not found in response');
} catch (e) {
subscription.cancel();
rethrow;
}
}
Future<void> completeRegistration(String regToken) async {
final payload = {
"lastName": "User",
"token": regToken,
"firstName": "Komet",
"tokenType": "REGISTER",
};
final completer = Completer<Map<String, dynamic>>();
final subscription = messages.listen((message) {
if (message['opcode'] == 23 && !completer.isCompleted) {
completer.complete(message);
}
});
_sendMessage(23, payload);
try {
await completer.future.timeout(const Duration(seconds: 30));
subscription.cancel();
print('Registration completed successfully');
} catch (e) {
subscription.cancel();
rethrow;
}
}
}

View File

@@ -16,6 +16,12 @@ extension ApiServiceContacts on ApiService {
_sendMessage(34, {'contactId': contactId, 'action': 'ADD'});
}
Future<void> requestContactsByIds(List<int> contactIds) async {
await waitUntilOnline();
_sendMessage(35, {'contactIds': contactIds});
print('Отправлен запрос opcode=35 с contactIds: $contactIds');
}
Future<void> subscribeToChat(int chatId, bool subscribe) async {
await waitUntilOnline();
_sendMessage(75, {'chatId': chatId, 'subscribe': subscribe});

View File

@@ -3,7 +3,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:gwid/api/api_service.dart';
import 'theme_provider.dart';
import 'utils/theme_provider.dart';
class ConnectionLifecycleManager extends StatefulWidget {
final Widget child;

2
lib/consts.dart Normal file
View File

@@ -0,0 +1,2 @@
// Датафайл с константами, полезно при изменении версии например
const version = "0.3.0";

View File

@@ -3,9 +3,9 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'home_screen.dart';
import 'phone_entry_screen.dart';
import 'theme_provider.dart';
import 'screens/home_screen.dart';
import 'screens/phone_entry_screen.dart';
import 'utils/theme_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';

View File

@@ -4,7 +4,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/models/channel.dart';
import 'package:gwid/search_channels_screen.dart';
import 'package:gwid/screens/search_channels_screen.dart';
class ChannelsListScreen extends StatefulWidget {
const ChannelsListScreen({super.key});

View File

@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'package:gwid/api/api_service.dart';
import 'package:flutter/services.dart';
import 'package:gwid/models/contact.dart';

View File

@@ -6,32 +6,31 @@ import 'package:flutter/scheduler.dart';
import 'package:intl/intl.dart';
import 'package:gwid/api/api_service.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:gwid/chat_screen.dart';
import 'package:gwid/manage_account_screen.dart';
import 'package:gwid/screens/chat_screen.dart';
import 'package:gwid/screens/manage_account_screen.dart';
import 'package:gwid/screens/settings/settings_screen.dart';
import 'package:gwid/phone_entry_screen.dart';
import 'package:gwid/screens/phone_entry_screen.dart';
import 'package:gwid/models/chat.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/models/message.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/models/chat_folder.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gwid/join_group_screen.dart';
import 'package:gwid/search_contact_screen.dart';
import 'package:gwid/channels_list_screen.dart';
import 'package:gwid/screens/join_group_screen.dart';
import 'package:gwid/screens/search_contact_screen.dart';
import 'package:gwid/screens/channels_list_screen.dart';
import 'package:gwid/models/channel.dart';
import 'package:gwid/search_channels_screen.dart';
import 'package:gwid/downloads_screen.dart';
import 'package:gwid/user_id_lookup_screen.dart';
import 'package:gwid/screens/search_channels_screen.dart';
import 'package:gwid/screens/downloads_screen.dart';
import 'package:gwid/utils/user_id_lookup_screen.dart';
import 'package:gwid/screens/music_library_screen.dart';
import 'package:gwid/widgets/message_preview_dialog.dart';
import 'package:gwid/services/chat_read_settings_service.dart';
import 'package:gwid/services/local_profile_manager.dart';
import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/widgets/contact_avatar_widget.dart';
import 'package:gwid/services/contact_local_names_service.dart';
import 'package:gwid/services/account_manager.dart';
import 'package:gwid/models/account.dart';
@@ -745,7 +744,7 @@ class _ChatsScreenState extends State<ChatsScreen>
fontSize: 16,
),
),
const Spacer(),
/*const Spacer(),
IconButton(
icon: const Icon(Icons.search, size: 20),
onPressed: () {
@@ -756,7 +755,7 @@ class _ChatsScreenState extends State<ChatsScreen>
);
},
tooltip: 'Поиск каналов',
),
),*/
],
),
),
@@ -995,7 +994,7 @@ class _ChatsScreenState extends State<ChatsScreen>
},
),
ListTile(
/*ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
@@ -1015,7 +1014,7 @@ class _ChatsScreenState extends State<ChatsScreen>
),
);
},
),
),*/
ListTile(
leading: CircleAvatar(
@@ -2032,7 +2031,24 @@ class _ChatsScreenState extends State<ChatsScreen>
color: colors.primary,
size: 20,
)
: null,
: IconButton(
icon: Icon(
Icons.close,
size: 20,
color: colors.onSurfaceVariant,
),
onPressed: () {
_showDeleteAccountDialog(
context,
account,
accountManager,
() {
// Обновляем список аккаунтов
setState(() {});
},
);
},
),
onTap: isCurrent
? null
: () async {
@@ -3711,6 +3727,57 @@ class _ChatsScreenState extends State<ChatsScreen>
}
}
void _showDeleteAccountDialog(
BuildContext context,
Account account,
AccountManager accountManager,
VoidCallback onDeleted,
) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Удаление аккаунта'),
content: const Text('Точно хочешь удалить аккаунт?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Нет'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await accountManager.removeAccount(account.id);
if (mounted) {
onDeleted();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Аккаунт удален'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Да'),
),
],
);
},
);
}
void _showSearchFilters() {
showModalBottomSheet(
context: context,

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart';
import 'package:gwid/cache_management_screen.dart'; // Добавлен импорт
import 'package:gwid/screens/cache_management_screen.dart'; // Добавлен импорт
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/phone_entry_screen.dart';
import 'package:gwid/custom_request_screen.dart';
import 'package:gwid/screens/phone_entry_screen.dart';
import 'package:gwid/screens/custom_request_screen.dart';
import 'dart:async';
class DebugScreen extends StatelessWidget {

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/chats_screen.dart';
import 'package:gwid/phone_entry_screen.dart';
import 'package:gwid/screens/chats_screen.dart';
import 'package:gwid/screens/phone_entry_screen.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/screens/settings/reconnection_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -10,9 +10,9 @@ import 'package:app_links/app_links.dart';
import 'package:gwid/models/chat.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/chat_screen.dart';
import 'package:gwid/screens/chat_screen.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/phone_entry_screen.dart';
import 'package:gwid/screens/phone_entry_screen.dart';
import 'package:gwid/services/profile_cache_service.dart';
import 'package:gwid/services/local_profile_manager.dart';
import 'package:image_picker/image_picker.dart';

View File

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:pinput/pinput.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/chats_screen.dart';
import 'package:gwid/password_auth_screen.dart';
import 'package:gwid/screens/chats_screen.dart';
import 'package:gwid/screens/password_auth_screen.dart';
class OTPScreen extends StatefulWidget {
final String phoneNumber;

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/chats_screen.dart';
import 'package:gwid/screens/chats_screen.dart';
class PasswordAuthScreen extends StatefulWidget {
const PasswordAuthScreen({super.key});

View File

@@ -4,14 +4,16 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/otp_screen.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/screens/otp_screen.dart';
import 'package:gwid/utils/proxy_service.dart';
import 'package:gwid/screens/registration_screen.dart';
import 'package:gwid/screens/settings/auth_settings_screen.dart';
import 'package:gwid/token_auth_screen.dart';
import 'package:gwid/tos_screen.dart'; // Импорт экрана ToS
import 'package:gwid/screens/token_auth_screen.dart';
import 'package:gwid/screens/tos_screen.dart'; // Импорт экрана ToS
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:platform_info/platform_info.dart';
class Country {
final String name;
@@ -48,6 +50,55 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
mask: '+7 (###) ###-##-##',
digits: 10,
),
Country(
name: 'Азербайджан',
code: '+994',
flag: '🇦🇿',
mask: '+994 (##) ###-##-##',
digits: 9,
),
Country(
name: 'Армения',
code: '+374',
flag: '🇦🇲',
mask: '+374 (##) ###-###',
digits: 8,
),
Country(
name: 'Казахстан',
code: '+7',
flag: '🇰🇿',
mask: '+7 (###) ###-##-##',
digits: 10,
),
Country(
name: 'Кыргызстан',
code: '+996',
flag: '🇰🇬',
mask: '+996 (###) ###-###',
digits: 9,
),
Country(
name: 'Молдова',
code: '+373',
flag: '🇲🇩',
mask: '+373 (####) ####',
digits: 8,
),
Country(
name: 'Таджикистан',
code: '+992',
flag: '🇹🇯',
mask: '+992 (##) ###-##-##',
digits: 9,
),
Country(
name: 'Узбекистан',
code: '+998',
flag: '🇺🇿',
mask: '+998 (##) ###-##-##',
digits: 9,
),
Country(
name: 'Беларусь',
code: '+375',
@@ -55,6 +106,13 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
mask: '+375 (##) ###-##-##',
digits: 9,
),
Country(
name: 'Свое',
code: '',
flag: '',
mask: '',
digits: 0, // Без ограничения
),
];
Country _selectedCountry = _countries[0];
@@ -66,6 +124,7 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
StreamSubscription? _apiSubscription;
bool _showContent = false;
bool _isTosAccepted = false; // Состояние для отслеживания принятия соглашения
String _customPrefix = ''; // Для "Свой префикс"
late final AnimationController _animationController;
late final Animation<Alignment> _topAlignmentAnimation;
@@ -120,8 +179,11 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
final payload = message['payload'];
if (payload != null && payload['token'] != null) {
final String token = payload['token'];
final String prefix = _selectedCountry.mask.isEmpty
? _customPrefix
: _selectedCountry.code;
final String fullPhoneNumber =
_selectedCountry.code + _maskFormatter.getUnmaskedText();
prefix + _maskFormatter.getUnmaskedText();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
@@ -141,6 +203,14 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
}
void _initializeMaskFormatter() {
if (_selectedCountry.mask.isEmpty) {
// Для "Свой префикс" - без маски, только цифры
_maskFormatter = MaskTextInputFormatter(
mask: '',
filter: {"#": RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
} else {
final mask = _selectedCountry.mask
.replaceFirst(RegExp(r'^\+\d+\s?'), '')
.trim();
@@ -150,6 +220,7 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
type: MaskAutoCompletionType.lazy,
);
}
}
void _onPhoneChanged() {
final text = _phoneController.text;
@@ -165,8 +236,11 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
});
}
}
final isFull =
_maskFormatter.getUnmaskedText().length == _selectedCountry.digits;
// Для "Свой префикс" проверяем минимальную длину (например, 5 цифр)
final isFull = _selectedCountry.mask.isEmpty
? _maskFormatter.getUnmaskedText().length >= 5
: _maskFormatter.getUnmaskedText().length == _selectedCountry.digits;
if (isFull != _isButtonEnabled) {
setState(() => _isButtonEnabled = isFull);
}
@@ -192,16 +266,77 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
return null;
}
void _onCountryChanged(Country? country) {
void _onCountryChanged(Country? country) async {
if (country != null && country != _selectedCountry) {
// Если выбран "Свой префикс", показываем диалог для ввода префикса
if (country.mask.isEmpty) {
final prefix = await _showCustomPrefixDialog();
if (prefix == null || prefix.isEmpty) {
return; // Отменено
}
setState(() {
_selectedCountry = country;
_customPrefix = prefix.startsWith('+') ? prefix : '+$prefix';
_phoneController.clear();
_initializeMaskFormatter();
_isButtonEnabled = false;
});
} else {
setState(() {
_selectedCountry = country;
_customPrefix = '';
_phoneController.clear();
_initializeMaskFormatter();
_isButtonEnabled = false;
});
}
}
}
Future<String?> _showCustomPrefixDialog() async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return AlertDialog(
title: Text(
'Введите код страны',
style: GoogleFonts.manrope(
textStyle: textTheme.titleLarge,
fontWeight: FontWeight.bold,
),
),
content: TextField(
controller: controller,
keyboardType: TextInputType.phone,
autofocus: true,
decoration: InputDecoration(
hintText: '+123',
prefixText: '+',
border: const OutlineInputBorder(),
),
style: GoogleFonts.manrope(textStyle: textTheme.titleMedium),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Отмена', style: GoogleFonts.manrope()),
),
FilledButton(
onPressed: () {
final prefix = controller.text.trim();
if (prefix.isNotEmpty) {
Navigator.of(context).pop(prefix);
}
},
child: Text('OK', style: GoogleFonts.manrope()),
),
],
);
},
);
}
void _checkAnonymitySettings() async {
final prefs = await SharedPreferences.getInstance();
@@ -221,8 +356,10 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
void _requestOtp() async {
if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return;
setState(() => _isLoading = true);
final String fullPhoneNumber =
_selectedCountry.code + _maskFormatter.getUnmaskedText();
final String prefix = _selectedCountry.mask.isEmpty
? _customPrefix
: _selectedCountry.code;
final String fullPhoneNumber = prefix + _maskFormatter.getUnmaskedText();
try {
ApiService.instance.errorStream.listen((error) {
if (mounted) {
@@ -341,7 +478,44 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
selectedCountry: _selectedCountry,
countries: _countries,
onCountryChanged: _onCountryChanged,
customPrefix: _customPrefix,
),
(Platform.instance.android || Platform.instance.windows) ? Column(
children: [
const SizedBox(height: 16),
Center(
child: TextButton(
onPressed: _isTosAccepted
? () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
const RegistrationScreen(),
),
);
}
: null,
child: Text(
'зарегистрироваться',
style: GoogleFonts.manrope(
color: _isTosAccepted
? colors.primary
: colors.onSurfaceVariant.withOpacity(
0.5,
),
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: _isTosAccepted
? colors.primary
: colors.onSurfaceVariant.withOpacity(
0.5,
),
),
),
),
)]
) : const SizedBox(),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -517,6 +691,7 @@ class _PhoneInput extends StatelessWidget {
final Country selectedCountry;
final List<Country> countries;
final ValueChanged<Country?> onCountryChanged;
final String customPrefix;
const _PhoneInput({
required this.phoneController,
@@ -524,6 +699,7 @@ class _PhoneInput extends StatelessWidget {
required this.selectedCountry,
required this.countries,
required this.onCountryChanged,
required this.customPrefix,
});
@override
@@ -542,6 +718,7 @@ class _PhoneInput extends StatelessWidget {
selectedCountry: selectedCountry,
countries: countries,
onCountryChanged: onCountryChanged,
customPrefix: customPrefix,
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
@@ -556,11 +733,13 @@ class _CountryPicker extends StatelessWidget {
final Country selectedCountry;
final List<Country> countries;
final ValueChanged<Country?> onCountryChanged;
final String customPrefix;
const _CountryPicker({
required this.selectedCountry,
required this.countries,
required this.onCountryChanged,
required this.customPrefix,
});
@override
@@ -575,16 +754,40 @@ class _CountryPicker extends StatelessWidget {
value: selectedCountry,
onChanged: onCountryChanged,
icon: Icon(Icons.keyboard_arrow_down, color: colors.onSurfaceVariant),
selectedItemBuilder: (BuildContext context) {
return countries.map<Widget>((Country country) {
final displayText = country.mask.isEmpty
? (customPrefix.isNotEmpty ? customPrefix : country.name)
: country.code;
return Padding(
padding: const EdgeInsets.only(left: 8.0, right: 4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayText,
style: GoogleFonts.manrope(
textStyle: textTheme.titleMedium,
fontWeight: FontWeight.w600,
),
),
],
),
);
}).toList();
},
items: countries.map((Country country) {
return DropdownMenuItem<Country>(
value: country,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (country.flag.isNotEmpty) ...[
Text(country.flag, style: textTheme.titleMedium),
const SizedBox(width: 8),
],
Text(
country.code,
country.code.isEmpty ? 'Свое' : country.code,
style: GoogleFonts.manrope(
textStyle: textTheme.titleMedium,
fontWeight: FontWeight.w600,

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:gwid/manage_account_screen.dart';
import 'package:gwid/screens/manage_account_screen.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/screens/settings/settings_screen.dart';
import 'package:gwid/phone_entry_screen.dart';
import 'package:gwid/screens/phone_entry_screen.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
class ProfileMenuDialog extends StatefulWidget {
final Profile? myProfile;

View File

@@ -0,0 +1,542 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:gwid/api/api_registration_service.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
class Country {
final String name;
final String code;
final String flag;
final String mask;
final int digits;
const Country({
required this.name,
required this.code,
required this.flag,
required this.mask,
required this.digits,
});
}
class RegistrationScreen extends StatefulWidget {
const RegistrationScreen({super.key});
@override
State<RegistrationScreen> createState() => _RegistrationScreenState();
}
class _RegistrationScreenState extends State<RegistrationScreen>
with TickerProviderStateMixin {
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _codeController = TextEditingController();
static const List<Country> _countries = [
Country(
name: 'Россия',
code: '+7',
flag: '🇷🇺',
mask: '+7 (###) ###-##-##',
digits: 10,
),
Country(
name: 'Беларусь',
code: '+375',
flag: '🇧🇾',
mask: '+375 (##) ###-##-##',
digits: 9,
),
];
Country _selectedCountry = _countries[0];
late MaskTextInputFormatter _maskFormatter;
bool _isButtonEnabled = false;
bool _isLoading = false;
bool _showCodeInput = false;
bool _showContent = false;
String? _registrationToken;
final RegistrationService _registrationService = RegistrationService();
late final AnimationController _animationController;
late final Animation<Alignment> _topAlignmentAnimation;
late final Animation<Alignment> _bottomAlignmentAnimation;
@override
void initState() {
super.initState();
print('🎬 RegistrationScreen инициализирован');
_animationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 15),
);
_topAlignmentAnimation =
AlignmentTween(
begin: Alignment.topLeft,
end: Alignment.topRight,
).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
),
);
_bottomAlignmentAnimation =
AlignmentTween(
begin: Alignment.bottomRight,
end: Alignment.bottomLeft,
).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
),
);
_animationController.repeat(reverse: true);
_initializeMaskFormatter();
_phoneController.addListener(_onPhoneChanged);
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) setState(() => _showContent = true);
});
}
void _initializeMaskFormatter() {
final mask = _selectedCountry.mask
.replaceFirst(RegExp(r'^\+\d+\s?'), '')
.trim();
_maskFormatter = MaskTextInputFormatter(
mask: mask,
filter: {"#": RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
}
void _onPhoneChanged() {
final text = _phoneController.text;
if (text.isNotEmpty) {
Country? detectedCountry = _detectCountryFromInput(text);
if (detectedCountry != null && detectedCountry != _selectedCountry) {
if (_shouldClearFieldForCountry(text, detectedCountry)) {
_phoneController.clear();
}
setState(() {
_selectedCountry = detectedCountry;
_initializeMaskFormatter();
});
}
}
final isFull =
_maskFormatter.getUnmaskedText().length == _selectedCountry.digits;
if (isFull != _isButtonEnabled) {
setState(() => _isButtonEnabled = isFull);
}
}
bool _shouldClearFieldForCountry(String input, Country country) {
final cleanInput = input.replaceAll(RegExp(r'[^\d+]'), '');
if (country.code == '+7') {
return !(cleanInput.startsWith('+7') || cleanInput.startsWith('7'));
} else if (country.code == '+375') {
return !(cleanInput.startsWith('+375') || cleanInput.startsWith('375'));
}
return true;
}
Country? _detectCountryFromInput(String input) {
final cleanInput = input.replaceAll(RegExp(r'[^\d+]'), '');
if (cleanInput.startsWith('+7') || cleanInput.startsWith('7')) {
return _countries.firstWhere((c) => c.code == '+7');
} else if (cleanInput.startsWith('+375') || cleanInput.startsWith('375')) {
return _countries.firstWhere((c) => c.code == '+375');
}
return null;
}
void _onCountryChanged(Country? country) {
if (country != null && country != _selectedCountry) {
setState(() {
_selectedCountry = country;
_phoneController.clear();
_initializeMaskFormatter();
_isButtonEnabled = false;
});
}
}
Future<void> _startRegistration() async {
if (!_isButtonEnabled || _isLoading) return;
print('🔄 Начинаем процесс регистрации...');
setState(() => _isLoading = true);
try {
final fullPhoneNumber =
_selectedCountry.code + _maskFormatter.getUnmaskedText();
print('📞 Номер телефона: $fullPhoneNumber');
// Запускаем процесс регистрации
final token = await _registrationService.startRegistration(
fullPhoneNumber,
);
print('✅ Токен получен: ${token.substring(0, 20)}...');
if (mounted) {
setState(() {
_isLoading = false;
_showCodeInput = true;
_registrationToken = token;
});
print('✅ Переходим к вводу кода');
}
} catch (e) {
print('❌ Ошибка в процессе регистрации: $e');
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка регистрации: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _verifyRegistrationCode(String code) async {
if (_registrationToken == null || _isLoading) return;
setState(() => _isLoading = true);
try {
print('🔐 Код подтверждения: $code');
// Проверяем код и получаем токен регистрации
final registerToken = await _registrationService.verifyCode(
_registrationToken!,
code,
);
// Завершаем регистрацию
await _registrationService.completeRegistration(registerToken);
print('✅ Регистрация завершена успешно!');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Регистрация завершена успешно!'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop();
}
} catch (e) {
print('❌ Ошибка при завершении регистрации: $e');
if (mounted) {
setState(() => _isLoading = false);
// Проверяем, существует ли уже аккаунт
if (e.toString().contains('ACCOUNT_EXISTS')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'А зачем... Аккаунт на таком номере уже существует!',
),
backgroundColor: Colors.orange,
),
);
// Закрываем экран регистрации
Navigator.of(context).pop();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: Stack(
children: [
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: _topAlignmentAnimation.value,
end: _bottomAlignmentAnimation.value,
colors: [
Color.lerp(colors.surface, colors.primary, 0.2)!,
Color.lerp(colors.surface, colors.tertiary, 0.15)!,
colors.surface,
Color.lerp(colors.surface, colors.secondary, 0.15)!,
Color.lerp(colors.surface, colors.primary, 0.25)!,
],
stops: const [0.0, 0.25, 0.5, 0.75, 1.0],
),
),
);
},
),
SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 340),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
opacity: _showContent ? 1.0 : 0.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.primary.withOpacity(0.1),
),
child: const Image(
image: AssetImage(
'assets/images/komet_512.png',
),
width: 75,
height: 75,
),
),
),
const SizedBox(height: 24),
Text(
'Модуль регистрации',
textAlign: TextAlign.center,
style: GoogleFonts.manrope(
textStyle: textTheme.headlineMedium,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 48),
if (!_showCodeInput) ...[
_PhoneInput(
phoneController: _phoneController,
maskFormatter: _maskFormatter,
selectedCountry: _selectedCountry,
countries: _countries,
onCountryChanged: _onCountryChanged,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _isButtonEnabled && !_isLoading
? _startRegistration
: null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: Text(
'Отправить код',
style: GoogleFonts.manrope(
fontWeight: FontWeight.bold,
),
),
),
] else ...[
Text(
'Введите код подтверждения',
textAlign: TextAlign.center,
style: GoogleFonts.manrope(
textStyle: textTheme.titleMedium,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 24),
TextFormField(
controller: _codeController,
keyboardType: TextInputType.number,
maxLength: 6,
textAlign: TextAlign.center,
style: GoogleFonts.manrope(
textStyle: Theme.of(
context,
).textTheme.headlineMedium,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
hintText: '000000',
counterText: '',
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
onChanged: (value) {
if (value.length == 6) {
_verifyRegistrationCode(value);
}
},
),
],
const SizedBox(height: 24),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Назад',
style: GoogleFonts.manrope(
color: colors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
),
),
),
if (_isLoading)
Container(
color: colors.scrim.withOpacity(0.7),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
colors.onPrimary,
),
),
const SizedBox(height: 16),
Text(
_showCodeInput ? 'Регистрируем...' : 'Отправляем код...',
style: textTheme.titleMedium?.copyWith(
color: colors.onPrimary,
),
),
],
),
),
),
],
),
);
}
@override
void dispose() {
_animationController.dispose();
_phoneController.dispose();
_codeController.dispose();
_registrationService.disconnect();
super.dispose();
}
}
class _PhoneInput extends StatelessWidget {
final TextEditingController phoneController;
final MaskTextInputFormatter maskFormatter;
final Country selectedCountry;
final List<Country> countries;
final ValueChanged<Country?> onCountryChanged;
const _PhoneInput({
required this.phoneController,
required this.maskFormatter,
required this.selectedCountry,
required this.countries,
required this.onCountryChanged,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: phoneController,
inputFormatters: [maskFormatter],
keyboardType: TextInputType.number,
style: GoogleFonts.manrope(
textStyle: Theme.of(context).textTheme.titleMedium,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
hintText: maskFormatter.getMask()?.replaceAll('#', '0'),
prefixIcon: _CountryPicker(
selectedCountry: selectedCountry,
countries: countries,
onCountryChanged: onCountryChanged,
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
autofocus: true,
);
}
}
class _CountryPicker extends StatelessWidget {
final Country selectedCountry;
final List<Country> countries;
final ValueChanged<Country?> onCountryChanged;
const _CountryPicker({
required this.selectedCountry,
required this.countries,
required this.onCountryChanged,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
margin: const EdgeInsets.only(left: 8),
child: DropdownButtonHideUnderline(
child: DropdownButton<Country>(
value: selectedCountry,
onChanged: onCountryChanged,
icon: Icon(Icons.keyboard_arrow_down, color: colors.onSurfaceVariant),
items: countries.map((Country country) {
return DropdownMenuItem<Country>(
value: country,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(country.flag, style: textTheme.titleMedium),
const SizedBox(width: 8),
Text(
country.code,
style: GoogleFonts.manrope(
textStyle: textTheme.titleMedium,
fontWeight: FontWeight.w600,
),
),
],
),
);
}).toList(),
),
),
);
}
}

View File

@@ -1,9 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/screens/chat_screen.dart';
class SearchContactScreen extends StatefulWidget {
const SearchContactScreen({super.key});
@@ -36,7 +35,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
_apiSubscription = ApiService.instance.messages.listen((message) {
if (!mounted) return;
if (message['type'] == 'contact_found') {
setState(() {
_isLoading = false;
@@ -48,8 +46,10 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
if (contactData != null) {
_foundContact = Contact.fromJson(contactData);
}
// Автоматически открываем чат с найденным контактом
_openChatWithContact(_foundContact!);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Контакт найден!'),
@@ -62,7 +62,7 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
),
);
}
}
if (message['type'] == 'contact_not_found') {
setState(() {
@@ -118,7 +118,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
return;
}
if (!phone.startsWith('+') || phone.length < 10) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -160,13 +159,21 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
}
}
void _startChat() {
if (_foundContact != null) {
Future<void> _openChatWithContact(Contact contact) async {
try {
print(
'🔍 Открываем чат с контактом: ${contact.name} (ID: ${contact.id})',
);
// Получаем chatId по contactId
final chatId = await ApiService.instance.getChatIdByUserId(contact.id);
if (chatId == null) {
print('⚠️ Чат не найден для контакта ${contact.id}');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Создание чата с ${_foundContact!.name}'),
backgroundColor: Colors.blue,
content: const Text('Не удалось найти чат с этим контактом'),
backgroundColor: Colors.orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -174,6 +181,124 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
margin: const EdgeInsets.all(10),
),
);
return;
}
print('✅ Найден chatId: $chatId');
// Подписываемся на чат
await ApiService.instance.subscribeToChat(chatId, true);
print('✅ Подписались на чат $chatId');
// Получаем myId из профиля
final profileData = ApiService.instance.lastChatsPayload?['profile'];
final contactProfile = profileData?['contact'] as Map<String, dynamic>?;
final myId = contactProfile?['id'] as int? ?? 0;
if (myId == 0) {
print('⚠️ Не удалось получить myId, используем 0');
}
// Открываем ChatScreen
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => ChatScreen(
chatId: chatId,
contact: contact,
myId: myId,
isGroupChat: false,
isChannel: false,
onChatUpdated: () {
print('Chat updated');
},
),
),
);
}
} catch (e) {
print('❌ Ошибка при открытии чата: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при открытии чата: ${e.toString()}'),
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(10),
),
);
}
}
}
void _startChat() {
if (_foundContact != null) {
_openChatWithContact(_foundContact!);
}
}
Future<void> _startChatAlternative() async {
if (_foundContact == null) return;
try {
setState(() {
_isLoading = true;
});
print('🔄 Альтернативный способ: добавляем контакт ${_foundContact!.id}');
// Отправляем opcode=34 с action="ADD"
await ApiService.instance.addContact(_foundContact!.id);
print('✅ Отправлен opcode=34 с action=ADD');
// Отправляем opcode=35 с contactIds
await ApiService.instance.requestContactsByIds([_foundContact!.id]);
print('✅ Отправлен opcode=35 с contactIds=[${_foundContact!.id}]');
if (mounted) {
setState(() {
_isLoading = false;
});
// Показываем диалог о необходимости перезайти
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Перезайти в приложение'),
content: const Text(
'Для завершения добавления контакта необходимо перезайти в приложение.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Понятно'),
),
],
),
);
}
} catch (e) {
print('❌ Ошибка при альтернативном способе: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка: ${e.toString()}'),
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(10),
),
);
}
}
}
@@ -194,7 +319,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -230,7 +354,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
const SizedBox(height: 24),
Text(
'Номер телефона',
style: Theme.of(
@@ -322,7 +445,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
),
),
if (_foundContact != null) ...[
const SizedBox(height: 24),
Container(
@@ -411,12 +533,27 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _startChatAlternative,
icon: const Icon(Icons.alternate_email),
label: const Text(
'Начать чат альтернативным способом',
),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 24),
Container(

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:gwid/tos_screen.dart';
import 'package:gwid/screens/tos_screen.dart';
import 'package:gwid/consts.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends StatelessWidget {
@@ -239,7 +240,7 @@ class AboutScreen extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Версия 0.3.0',
'Версия $version',
style: TextStyle(
fontSize: 16,
color: colors.onSurface.withOpacity(0.7),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
class AnimationsScreen extends StatelessWidget {

View File

@@ -2,7 +2,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'package:gwid/screens/settings/customization_screen.dart';
import 'package:gwid/screens/settings/animations_screen.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/utils/proxy_service.dart';
import 'package:gwid/screens/settings/proxy_settings_screen.dart';
import 'package:gwid/screens/settings/session_spoofing_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
class BypassScreen extends StatelessWidget {
final bool isModal;

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'dart:io';
import 'dart:ui';
import 'package:gwid/models/message.dart';

View File

@@ -4,8 +4,8 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/spoofing_service.dart';
import 'package:gwid/utils/proxy_service.dart';
import 'package:gwid/utils/spoofing_service.dart';
import 'package:encrypt/encrypt.dart' as encrypt;
import 'package:crypto/crypto.dart' as crypto;

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gwid/password_management_screen.dart';
import 'package:gwid/screens/password_management_screen.dart';
class PrivacySettingsScreen extends StatefulWidget {
const PrivacySettingsScreen({super.key});

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/proxy_settings.dart';
import 'package:gwid/utils/proxy_service.dart';
import 'package:gwid/utils/proxy_settings.dart';
class ProxySettingsScreen extends StatefulWidget {
const ProxySettingsScreen({super.key});

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/home_screen.dart';
import 'package:gwid/screens/home_screen.dart';
class ReconnectionScreen extends StatefulWidget {
const ReconnectionScreen({super.key});

View File

@@ -8,7 +8,7 @@ import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gwid/api/api_service.dart';
import 'package:uuid/uuid.dart';
import 'package:gwid/device_presets.dart';
import 'package:gwid/utils/device_presets.dart';
enum SpoofingMethod { partial, full }

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:gwid/consts.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/manage_account_screen.dart';
import 'package:gwid/screens/manage_account_screen.dart';
import 'package:gwid/screens/settings/appearance_settings_screen.dart';
import 'package:gwid/screens/settings/notification_settings_screen.dart';
import 'package:gwid/screens/settings/privacy_security_screen.dart';
@@ -9,9 +10,9 @@ import 'package:gwid/screens/settings/storage_screen.dart';
import 'package:gwid/screens/settings/network_settings_screen.dart';
import 'package:gwid/screens/settings/bypass_screen.dart';
import 'package:gwid/screens/settings/about_screen.dart';
import 'package:gwid/debug_screen.dart';
import 'package:gwid/screens/debug_screen.dart';
import 'package:gwid/screens/settings/komet_misc_screen.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'package:provider/provider.dart';
class SettingsScreen extends StatefulWidget {
@@ -208,7 +209,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
)
: null,
),
body: _buildSettingsContent(),
body: SafeArea(
child: _buildSettingsContent(),
),
);
}
@@ -468,7 +471,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Text(
'v0.3.0-beta.1',
version,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(

View File

@@ -11,9 +11,9 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/home_screen.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/proxy_settings.dart';
import 'package:gwid/screens/home_screen.dart';
import 'package:gwid/utils/proxy_service.dart';
import 'package:gwid/utils/proxy_settings.dart';
import 'package:gwid/screens/settings/qr_scanner_screen.dart';
import 'package:gwid/screens/settings/session_spoofing_screen.dart';

View File

@@ -9,26 +9,30 @@ import 'package:crypto/crypto.dart' as crypto;
import 'package:intl/intl.dart';
import 'package:gwid/models/message.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/utils/theme_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import 'dart:ui';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:gwid/chat_screen.dart';
import 'package:gwid/screens/chat_screen.dart';
import 'package:gwid/services/avatar_cache_service.dart';
import 'package:gwid/api/api_service.dart';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:open_file/open_file.dart';
import 'package:gwid/full_screen_video_player.dart';
import 'package:gwid/widgets/full_screen_video_player.dart';
import 'package:just_audio/just_audio.dart';
import 'package:gwid/services/cache_service.dart';
import 'package:video_player/video_player.dart';
import 'package:gwid/services/music_player_service.dart';
import 'package:platform_info/platform_info.dart';
bool _currentIsDark = false;
bool isMobile = Platform.instance.operatingSystem.iOS ||
Platform.instance.operatingSystem.android;
enum MessageReadStatus {
sending, // Отправляется (часы)
sent, // Отправлено (1 галочка)
@@ -1081,12 +1085,22 @@ class ChatMessageBubble extends StatelessWidget {
);
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) {
messageContent = GestureDetector(
onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: messageContent,
);
} else {
messageContent = GestureDetector(
onSecondaryTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: messageContent,
);
}
}
return Column(
@@ -1535,12 +1549,21 @@ class ChatMessageBubble extends StatelessWidget {
);
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) {
videoContent = GestureDetector(
onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: videoContent,
);
} else {
videoContent = GestureDetector(
onSecondaryTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: videoContent,
);
}
}
return videoContent;
@@ -1621,12 +1644,21 @@ class ChatMessageBubble extends StatelessWidget {
);
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) {
photoContent = GestureDetector(
onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: photoContent,
);
} else {
photoContent = GestureDetector(
onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: photoContent,
);
}
}
return photoContent;
@@ -1749,12 +1781,21 @@ class ChatMessageBubble extends StatelessWidget {
);
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
if (isMobile) {
videoContent = GestureDetector(
onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: videoContent,
);
} else {
videoContent = GestureDetector(
onSecondaryTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: videoContent,
);
}
}
return videoContent;

View File

@@ -0,0 +1,935 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'dart:async';
class FullScreenVideoPlayer extends StatefulWidget {
final String videoUrl;
const FullScreenVideoPlayer({Key? key, required this.videoUrl})
: super(key: key);
@override
State<FullScreenVideoPlayer> createState() => _FullScreenVideoPlayerState();
}
class _FullScreenVideoPlayerState extends State<FullScreenVideoPlayer>
with SingleTickerProviderStateMixin {
VideoPlayerController? _videoPlayerController;
bool _isLoading = true;
bool _hasError = false;
bool _isPlaying = false;
bool _showControls = true;
bool _isBuffering = false;
double _playbackSpeed = 1.0;
Timer? _hideControlsTimer;
Timer? _positionTimer;
late AnimationController _controlsAnimationController;
late Animation<double> _controlsAnimation;
bool _isDragging = false;
Duration _currentPosition = Duration.zero;
Duration _totalDuration = Duration.zero;
List<DurationRange> _bufferedRanges = [];
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
_controlsAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_controlsAnimation = CurvedAnimation(
parent: _controlsAnimationController,
curve: Curves.easeInOut,
);
_controlsAnimationController.forward();
_initializePlayer();
}
Future<void> _initializePlayer() async {
try {
_videoPlayerController = VideoPlayerController.networkUrl(
Uri.parse(widget.videoUrl),
httpHeaders: const {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
);
_videoPlayerController!.addListener(_videoListener);
await _videoPlayerController!.initialize();
_videoPlayerController!.play();
if (mounted) {
setState(() {
_isLoading = false;
_isPlaying = true;
_totalDuration = _videoPlayerController!.value.duration;
_currentPosition = _videoPlayerController!.value.position;
});
_startHideControlsTimer();
_startPositionTimer();
}
} catch (e) {
print('❌ [FullScreenVideoPlayer] Error initializing player: $e');
if (mounted) {
setState(() {
_hasError = true;
_isLoading = false;
});
}
}
}
void _videoListener() {
if (!mounted) return;
final controller = _videoPlayerController!;
setState(() {
_isPlaying = controller.value.isPlaying;
_isBuffering = controller.value.isBuffering;
_totalDuration = controller.value.duration;
_bufferedRanges = controller.value.buffered;
if (!_isDragging) {
_currentPosition = controller.value.position;
}
});
}
void _startHideControlsTimer() {
_hideControlsTimer?.cancel();
_hideControlsTimer = Timer(const Duration(seconds: 3), () {
if (_isPlaying && !_isDragging) {
_hideControlsUI();
}
});
}
void _startPositionTimer() {
_positionTimer?.cancel();
_positionTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (!mounted || _isDragging) return;
if (_videoPlayerController != null &&
_videoPlayerController!.value.isInitialized) {
setState(() {
_currentPosition = _videoPlayerController!.value.position;
});
}
});
}
void _showControlsUI() {
if (_showControls) return;
setState(() {
_showControls = true;
});
_controlsAnimationController.forward();
_startHideControlsTimer();
}
void _hideControlsUI() {
if (!_showControls) return;
setState(() {
_showControls = false;
});
_controlsAnimationController.reverse();
}
void _togglePlayPause() {
setState(() {
if (_isPlaying) {
_videoPlayerController!.pause();
_showControlsUI();
} else {
_videoPlayerController!.play();
_startHideControlsTimer();
}
});
}
Duration _clampDuration(Duration value, Duration min, Duration max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
void _seekTo(Duration position) {
_videoPlayerController!.seekTo(position);
setState(() {
_currentPosition = position;
_isDragging = false;
});
_startHideControlsTimer();
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(seconds)}';
}
return '${twoDigits(minutes)}:${twoDigits(seconds)}';
}
void _showSpeedMenu() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => _SpeedBottomSheet(
currentSpeed: _playbackSpeed,
onSpeedSelected: (speed) {
setState(() {
_playbackSpeed = speed;
_videoPlayerController!.setPlaybackSpeed(speed);
});
Navigator.pop(context);
_showControlsUI();
},
),
);
}
@override
void dispose() {
_hideControlsTimer?.cancel();
_positionTimer?.cancel();
_videoPlayerController?.removeListener(_videoListener);
_videoPlayerController?.dispose();
_controlsAnimationController.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (_showControls) {
_hideControlsUI();
} else {
_showControlsUI();
}
},
onDoubleTapDown: (details) {
final screenWidth = MediaQuery.of(context).size.width;
if (details.globalPosition.dx < screenWidth / 2) {
final newPosition = _clampDuration(
_currentPosition - const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
} else {
final newPosition = _clampDuration(
_currentPosition + const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
}
},
child: Stack(
children: [
Center(
child: _isLoading
? CircularProgressIndicator(
color: colorScheme.primary,
)
: _hasError
? _ErrorWidget(colorScheme: colorScheme)
: _videoPlayerController != null &&
_videoPlayerController!.value.isInitialized
? AspectRatio(
aspectRatio: _videoPlayerController!.value.aspectRatio,
child: VideoPlayer(_videoPlayerController!),
)
: const SizedBox(),
),
if (_isBuffering)
Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: colorScheme.primary,
strokeWidth: 3,
),
const SizedBox(height: 12),
Text(
'Буферизация...',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
if (_showControls)
GestureDetector(
onDoubleTapDown: (details) {
final screenWidth = MediaQuery.of(context).size.width;
if (details.globalPosition.dx < screenWidth / 2) {
final newPosition = _clampDuration(
_currentPosition - const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
} else {
final newPosition = _clampDuration(
_currentPosition + const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
}
},
behavior: HitTestBehavior.translucent,
child: AnimatedBuilder(
animation: _controlsAnimation,
builder: (context, child) {
return Opacity(
opacity: _controlsAnimation.value,
child: child,
);
},
child: _VideoControls(
colorScheme: colorScheme,
isPlaying: _isPlaying,
currentPosition: _currentPosition,
totalDuration: _totalDuration,
bufferedRanges: _bufferedRanges,
playbackSpeed: _playbackSpeed,
onPlayPause: _togglePlayPause,
onSeek: (position) {
setState(() {
_isDragging = true;
_currentPosition = position;
});
},
onSeekEnd: (position) {
_seekTo(position);
},
onBack: () => Navigator.pop(context),
onSpeedTap: () {
_showSpeedMenu();
},
onRewind: () {
final newPosition = _clampDuration(
_currentPosition - const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
},
onForward: () {
final newPosition = _clampDuration(
_currentPosition + const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
},
formatDuration: _formatDuration,
),
),
),
],
),
);
}
}
class _VideoControls extends StatelessWidget {
final ColorScheme colorScheme;
final bool isPlaying;
final Duration currentPosition;
final Duration totalDuration;
final List<DurationRange> bufferedRanges;
final double playbackSpeed;
final VoidCallback onPlayPause;
final Function(Duration) onSeek;
final Function(Duration) onSeekEnd;
final VoidCallback onBack;
final VoidCallback onSpeedTap;
final VoidCallback onRewind;
final VoidCallback onForward;
final String Function(Duration) formatDuration;
const _VideoControls({
required this.colorScheme,
required this.isPlaying,
required this.currentPosition,
required this.totalDuration,
required this.bufferedRanges,
required this.playbackSpeed,
required this.onPlayPause,
required this.onSeek,
required this.onSeekEnd,
required this.onBack,
required this.onSpeedTap,
required this.onRewind,
required this.onForward,
required this.formatDuration,
});
@override
Widget build(BuildContext context) {
final progress = totalDuration.inMilliseconds > 0
? currentPosition.inMilliseconds / totalDuration.inMilliseconds
: 0.0;
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
style: IconButton.styleFrom(
backgroundColor: Colors.black.withOpacity(0.5),
foregroundColor: Colors.white,
shape: const CircleBorder(),
),
),
const Spacer(),
FilledButton.tonal(
onPressed: onSpeedTap,
style: FilledButton.styleFrom(
backgroundColor: Colors.black.withOpacity(0.5),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.speed, size: 18),
const SizedBox(width: 6),
Text(
'${playbackSpeed}x',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
const Spacer(),
// Прогресс-бар
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_CustomProgressBar(
progress: progress,
currentPosition: currentPosition,
totalDuration: totalDuration,
bufferedRanges: bufferedRanges,
onSeek: onSeek,
onSeekEnd: onSeekEnd,
colorScheme: colorScheme,
formatDuration: formatDuration,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
formatDuration(currentPosition),
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const Text(
' / ',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
Text(
formatDuration(totalDuration),
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_MaterialYouControlButton(
icon: Icons.replay_10,
onTap: onRewind,
colorScheme: colorScheme,
label: '-10',
),
const SizedBox(width: 12),
_MaterialYouControlButton(
icon: isPlaying ? Icons.pause : Icons.play_arrow,
onTap: onPlayPause,
colorScheme: colorScheme,
isPrimary: true,
),
const SizedBox(width: 12),
// Кнопка перемотки вперед
_MaterialYouControlButton(
icon: Icons.forward_10,
onTap: onForward,
colorScheme: colorScheme,
label: '+10',
),
],
),
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
}
class _MaterialYouControlButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final ColorScheme colorScheme;
final String? label;
final bool isPrimary;
const _MaterialYouControlButton({
required this.icon,
required this.onTap,
required this.colorScheme,
this.label,
this.isPrimary = false,
});
@override
Widget build(BuildContext context) {
if (isPrimary) {
return FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.all(20),
shape: const CircleBorder(),
minimumSize: const Size(72, 72),
elevation: 3,
),
child: Icon(icon, size: 36),
);
} else {
return FilledButton.tonal(
onPressed: onTap,
style: FilledButton.styleFrom(
backgroundColor: Colors.white.withOpacity(0.16),
foregroundColor: Colors.white,
padding: const EdgeInsets.all(14),
shape: const CircleBorder(),
minimumSize: const Size(60, 60),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 26),
if (label != null) ...[
const SizedBox(height: 2),
Text(
label!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.0,
),
),
],
],
),
);
}
}
}
class _CustomProgressBar extends StatefulWidget {
final double progress;
final Duration currentPosition;
final Duration totalDuration;
final List<DurationRange> bufferedRanges;
final Function(Duration) onSeek;
final Function(Duration) onSeekEnd;
final ColorScheme colorScheme;
final String Function(Duration) formatDuration;
const _CustomProgressBar({
required this.progress,
required this.currentPosition,
required this.totalDuration,
required this.bufferedRanges,
required this.onSeek,
required this.onSeekEnd,
required this.colorScheme,
required this.formatDuration,
});
@override
State<_CustomProgressBar> createState() => _CustomProgressBarState();
}
class _CustomProgressBarState extends State<_CustomProgressBar> {
bool _isDragging = false;
double _dragProgress = 0.0;
Duration _getPositionFromLocalPosition(Offset localPosition, Size size) {
final progress = (localPosition.dx / size.width).clamp(0.0, 1.0);
return Duration(
milliseconds: (progress * widget.totalDuration.inMilliseconds).round(),
);
}
@override
Widget build(BuildContext context) {
final progress = _isDragging ? _dragProgress : widget.progress;
final currentPos = Duration(
milliseconds: (progress * widget.totalDuration.inMilliseconds).round(),
);
return GestureDetector(
onPanStart: (details) {
setState(() {
_isDragging = true;
});
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
_dragProgress = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
final position = _getPositionFromLocalPosition(localPosition, box.size);
widget.onSeek(position);
},
onPanUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
setState(() {
_dragProgress = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
});
final position = _getPositionFromLocalPosition(localPosition, box.size);
widget.onSeek(position);
},
onPanEnd: (details) {
setState(() {
_isDragging = false;
});
widget.onSeekEnd(currentPos);
},
onTapDown: (details) {
if (_isDragging) return;
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
final position = _getPositionFromLocalPosition(localPosition, box.size);
widget.onSeekEnd(position);
},
child: Container(
height: 48,
child: LayoutBuilder(
builder: (context, constraints) {
final containerWidth = constraints.maxWidth;
return Stack(
children: [
Center(
child: Container(
height: 4,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
if (widget.totalDuration.inMilliseconds > 0)
...widget.bufferedRanges.map((range) {
final startProgress = (range.start.inMilliseconds / widget.totalDuration.inMilliseconds).clamp(0.0, 1.0);
final endProgress = (range.end.inMilliseconds / widget.totalDuration.inMilliseconds).clamp(0.0, 1.0);
final bufferedWidth = (endProgress - startProgress).clamp(0.0, 1.0);
if (bufferedWidth <= 0) return const SizedBox.shrink();
final leftOffset = startProgress * containerWidth;
final bufferedWidthPx = bufferedWidth * containerWidth;
return Positioned(
left: leftOffset,
top: 22,
child: Container(
width: bufferedWidthPx,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(2),
),
),
);
}).toList(),
Center(
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: Container(
height: 4,
decoration: BoxDecoration(
color: widget.colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
// Ползунок
Center(
child: Align(
alignment: Alignment(progress * 2 - 1, 0),
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: widget.colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _SpeedBottomSheet extends StatelessWidget {
final double currentSpeed;
final Function(double) onSpeedSelected;
const _SpeedBottomSheet({
required this.currentSpeed,
required this.onSpeedSelected,
});
@override
Widget build(BuildContext context) {
final speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
decoration: BoxDecoration(
color: theme.dialogBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.symmetric(vertical: 24),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Text(
'Скорость воспроизведения',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: speeds.map((speed) {
final isSelected = speed == currentSpeed;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => onSpeedSelected(speed),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${speed}x',
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
),
);
}).toList(),
),
const SizedBox(height: 24),
],
),
),
);
}
}
class _ErrorWidget extends StatelessWidget {
final ColorScheme colorScheme;
const _ErrorWidget({required this.colorScheme});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.onErrorContainer,
size: 48,
),
),
const SizedBox(height: 24),
Text(
'Не удалось загрузить видео',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Проверьте подключение к интернету\nили попробуйте позже',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
);
}
}

View File

@@ -7,7 +7,7 @@ import 'package:gwid/models/profile.dart';
import 'package:gwid/api/api_service.dart';
import 'package:gwid/widgets/chat_message_bubble.dart';
import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/chat_screen.dart';
import 'package:gwid/screens/chat_screen.dart';
class ControlMessageChip extends StatelessWidget {
final Message message;

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

42
macos/Podfile Normal file
View File

@@ -0,0 +1,42 @@
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

View File

@@ -27,6 +27,8 @@
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 */; };
3BE50CEC3B857AFB79ED4B51 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 388CA5C37E59612ABAEE6B9C /* Pods_RunnerTests.framework */; };
CDF433146D1871A7EB701871 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 89C4DA26E1D86992C17E0203 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -60,11 +62,13 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1513A8E85A871669A708EFD4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
1C6F713FF81D9CB5A449F94D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* gwid.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "gwid.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* gwid.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gwid.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -76,8 +80,14 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
388CA5C37E59612ABAEE6B9C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
49D5476057F69A4097C12C58 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
6C72571057485AEF9EBFBCC3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
8028056045BFBF1AE7C02DD1 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
89C4DA26E1D86992C17E0203 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
FE5B3021B816A2FD94CBB7E1 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -85,6 +95,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3BE50CEC3B857AFB79ED4B51 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -92,6 +103,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CDF433146D1871A7EB701871 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -125,6 +137,7 @@
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
C9FFC0A80D79F5C5380DF8BD /* Pods */,
);
sourceTree = "<group>";
};
@@ -172,9 +185,25 @@
path = Runner;
sourceTree = "<group>";
};
C9FFC0A80D79F5C5380DF8BD /* Pods */ = {
isa = PBXGroup;
children = (
1513A8E85A871669A708EFD4 /* Pods-Runner.debug.xcconfig */,
49D5476057F69A4097C12C58 /* Pods-Runner.release.xcconfig */,
FE5B3021B816A2FD94CBB7E1 /* Pods-Runner.profile.xcconfig */,
6C72571057485AEF9EBFBCC3 /* Pods-RunnerTests.debug.xcconfig */,
1C6F713FF81D9CB5A449F94D /* Pods-RunnerTests.release.xcconfig */,
8028056045BFBF1AE7C02DD1 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
89C4DA26E1D86992C17E0203 /* Pods_Runner.framework */,
388CA5C37E59612ABAEE6B9C /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -186,6 +215,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
FBA29210F50B351DCEE6280B /* [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 = (
FBA311D2EFFD2A674FD24C1D /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
2DB171D51BA2A164FD6822FA /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -291,6 +323,23 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2DB171D51BA2A164FD6822FA /* [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;
};
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -329,6 +378,50 @@
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
FBA29210F50B351DCEE6280B /* [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;
};
FBA311D2EFFD2A674FD24C1D /* [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 = 6C72571057485AEF9EBFBCC3 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@@ -394,6 +488,7 @@
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 1C6F713FF81D9CB5A449F94D /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@@ -408,6 +503,7 @@
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 8028056045BFBF1AE7C02DD1 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;

View File

@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -8,5 +8,9 @@
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

View File

@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<string>Komet</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View File

@@ -4,5 +4,9 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@@ -108,6 +108,8 @@ dependencies:
msgpack_dart: ^1.0.1
ffi: ^2.1.0
disable_battery_optimization: ^1.1.2
flutter_highlight: ^0.7.0
@@ -123,6 +125,7 @@ dependencies:
chewie: ^1.7.5
just_audio: ^0.9.40
platform_info: ^5.0.0
dev_dependencies:
flutter_test: