File picker permissions and header rename. Also merged jija shit

This commit is contained in:
nullpeer
2025-11-30 14:07:33 +03:00
parent d793498d0a
commit 803a855c21
18 changed files with 2820 additions and 1611 deletions

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
pubspec.lock

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

@@ -255,4 +255,113 @@ extension ApiServiceAuth on ApiService {
rethrow; 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'}); _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 { Future<void> subscribeToChat(int chatId, bool subscribe) async {
await waitUntilOnline(); await waitUntilOnline();
_sendMessage(75, {'chatId': chatId, 'subscribe': subscribe}); _sendMessage(75, {'chatId': chatId, 'subscribe': subscribe});

2
lib/consts.dart Normal file
View File

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

View File

@@ -31,7 +31,6 @@ import 'package:gwid/services/chat_read_settings_service.dart';
import 'package:gwid/services/local_profile_manager.dart'; import 'package:gwid/services/local_profile_manager.dart';
import 'package:gwid/widgets/contact_name_widget.dart'; import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/widgets/contact_avatar_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/services/account_manager.dart';
import 'package:gwid/models/account.dart'; import 'package:gwid/models/account.dart';
@@ -2032,7 +2031,24 @@ class _ChatsScreenState extends State<ChatsScreen>
color: colors.primary, color: colors.primary,
size: 20, size: 20,
) )
: null, : IconButton(
icon: Icon(
Icons.close,
size: 20,
color: colors.onSurfaceVariant,
),
onPressed: () {
_showDeleteAccountDialog(
context,
account,
accountManager,
() {
// Обновляем список аккаунтов
setState(() {});
},
);
},
),
onTap: isCurrent onTap: isCurrent
? null ? null
: () async { : () async {
@@ -3710,6 +3726,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() { void _showSearchFilters() {
showModalBottomSheet( showModalBottomSheet(

View File

@@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:gwid/api/api_service.dart'; import 'package:gwid/api/api_service.dart';
import 'package:gwid/screens/otp_screen.dart'; import 'package:gwid/screens/otp_screen.dart';
import 'package:gwid/utils/proxy_service.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/screens/settings/auth_settings_screen.dart';
import 'package:gwid/screens/token_auth_screen.dart'; import 'package:gwid/screens/token_auth_screen.dart';
import 'package:gwid/screens/tos_screen.dart'; // Импорт экрана ToS import 'package:gwid/screens/tos_screen.dart'; // Импорт экрана ToS
@@ -48,6 +49,55 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
mask: '+7 (###) ###-##-##', mask: '+7 (###) ###-##-##',
digits: 10, 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( Country(
name: 'Беларусь', name: 'Беларусь',
code: '+375', code: '+375',
@@ -55,6 +105,13 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
mask: '+375 (##) ###-##-##', mask: '+375 (##) ###-##-##',
digits: 9, digits: 9,
), ),
Country(
name: 'Свое',
code: '',
flag: '',
mask: '',
digits: 0, // Без ограничения
),
]; ];
Country _selectedCountry = _countries[0]; Country _selectedCountry = _countries[0];
@@ -66,6 +123,7 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
StreamSubscription? _apiSubscription; StreamSubscription? _apiSubscription;
bool _showContent = false; bool _showContent = false;
bool _isTosAccepted = false; // Состояние для отслеживания принятия соглашения bool _isTosAccepted = false; // Состояние для отслеживания принятия соглашения
String _customPrefix = ''; // Для "Свой префикс"
late final AnimationController _animationController; late final AnimationController _animationController;
late final Animation<Alignment> _topAlignmentAnimation; late final Animation<Alignment> _topAlignmentAnimation;
@@ -120,8 +178,11 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
final payload = message['payload']; final payload = message['payload'];
if (payload != null && payload['token'] != null) { if (payload != null && payload['token'] != null) {
final String token = payload['token']; final String token = payload['token'];
final String prefix = _selectedCountry.mask.isEmpty
? _customPrefix
: _selectedCountry.code;
final String fullPhoneNumber = final String fullPhoneNumber =
_selectedCountry.code + _maskFormatter.getUnmaskedText(); prefix + _maskFormatter.getUnmaskedText();
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) =>
@@ -141,14 +202,23 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
} }
void _initializeMaskFormatter() { void _initializeMaskFormatter() {
final mask = _selectedCountry.mask if (_selectedCountry.mask.isEmpty) {
.replaceFirst(RegExp(r'^\+\d+\s?'), '') // Для "Свой префикс" - без маски, только цифры
.trim(); _maskFormatter = MaskTextInputFormatter(
_maskFormatter = MaskTextInputFormatter( mask: '',
mask: mask, filter: {"#": RegExp(r'[0-9]')},
filter: {"#": RegExp(r'[0-9]')}, type: MaskAutoCompletionType.lazy,
type: MaskAutoCompletionType.lazy, );
); } else {
final mask = _selectedCountry.mask
.replaceFirst(RegExp(r'^\+\d+\s?'), '')
.trim();
_maskFormatter = MaskTextInputFormatter(
mask: mask,
filter: {"#": RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
}
} }
void _onPhoneChanged() { void _onPhoneChanged() {
@@ -165,8 +235,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) { if (isFull != _isButtonEnabled) {
setState(() => _isButtonEnabled = isFull); setState(() => _isButtonEnabled = isFull);
} }
@@ -192,17 +265,78 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
return null; return null;
} }
void _onCountryChanged(Country? country) { void _onCountryChanged(Country? country) async {
if (country != null && country != _selectedCountry) { if (country != null && country != _selectedCountry) {
setState(() { // Если выбран "Свой префикс", показываем диалог для ввода префикса
_selectedCountry = country; if (country.mask.isEmpty) {
_phoneController.clear(); final prefix = await _showCustomPrefixDialog();
_initializeMaskFormatter(); if (prefix == null || prefix.isEmpty) {
_isButtonEnabled = false; 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 { void _checkAnonymitySettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final anonymityEnabled = prefs.getBool('anonymity_enabled') ?? false; final anonymityEnabled = prefs.getBool('anonymity_enabled') ?? false;
@@ -221,8 +355,10 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
void _requestOtp() async { void _requestOtp() async {
if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return; if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
final String fullPhoneNumber = final String prefix = _selectedCountry.mask.isEmpty
_selectedCountry.code + _maskFormatter.getUnmaskedText(); ? _customPrefix
: _selectedCountry.code;
final String fullPhoneNumber = prefix + _maskFormatter.getUnmaskedText();
try { try {
ApiService.instance.errorStream.listen((error) { ApiService.instance.errorStream.listen((error) {
if (mounted) { if (mounted) {
@@ -341,6 +477,39 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
selectedCountry: _selectedCountry, selectedCountry: _selectedCountry,
countries: _countries, countries: _countries,
onCountryChanged: _onCountryChanged, onCountryChanged: _onCountryChanged,
customPrefix: _customPrefix,
),
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(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -517,6 +686,7 @@ class _PhoneInput extends StatelessWidget {
final Country selectedCountry; final Country selectedCountry;
final List<Country> countries; final List<Country> countries;
final ValueChanged<Country?> onCountryChanged; final ValueChanged<Country?> onCountryChanged;
final String customPrefix;
const _PhoneInput({ const _PhoneInput({
required this.phoneController, required this.phoneController,
@@ -524,6 +694,7 @@ class _PhoneInput extends StatelessWidget {
required this.selectedCountry, required this.selectedCountry,
required this.countries, required this.countries,
required this.onCountryChanged, required this.onCountryChanged,
required this.customPrefix,
}); });
@override @override
@@ -542,6 +713,7 @@ class _PhoneInput extends StatelessWidget {
selectedCountry: selectedCountry, selectedCountry: selectedCountry,
countries: countries, countries: countries,
onCountryChanged: onCountryChanged, onCountryChanged: onCountryChanged,
customPrefix: customPrefix,
), ),
border: const OutlineInputBorder( border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
@@ -556,11 +728,13 @@ class _CountryPicker extends StatelessWidget {
final Country selectedCountry; final Country selectedCountry;
final List<Country> countries; final List<Country> countries;
final ValueChanged<Country?> onCountryChanged; final ValueChanged<Country?> onCountryChanged;
final String customPrefix;
const _CountryPicker({ const _CountryPicker({
required this.selectedCountry, required this.selectedCountry,
required this.countries, required this.countries,
required this.onCountryChanged, required this.onCountryChanged,
required this.customPrefix,
}); });
@override @override
@@ -575,16 +749,40 @@ class _CountryPicker extends StatelessWidget {
value: selectedCountry, value: selectedCountry,
onChanged: onCountryChanged, onChanged: onCountryChanged,
icon: Icon(Icons.keyboard_arrow_down, color: colors.onSurfaceVariant), 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) { items: countries.map((Country country) {
return DropdownMenuItem<Country>( return DropdownMenuItem<Country>(
value: country, value: country,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(country.flag, style: textTheme.titleMedium), if (country.flag.isNotEmpty) ...[
const SizedBox(width: 8), Text(country.flag, style: textTheme.titleMedium),
const SizedBox(width: 8),
],
Text( Text(
country.code, country.code.isEmpty ? 'Свое' : country.code,
style: GoogleFonts.manrope( style: GoogleFonts.manrope(
textStyle: textTheme.titleMedium, textStyle: textTheme.titleMedium,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

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 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gwid/api/api_service.dart'; import 'package:gwid/api/api_service.dart';
import 'package:gwid/models/contact.dart'; import 'package:gwid/models/contact.dart';
import 'package:gwid/screens/chat_screen.dart';
class SearchContactScreen extends StatefulWidget { class SearchContactScreen extends StatefulWidget {
const SearchContactScreen({super.key}); const SearchContactScreen({super.key});
@@ -36,7 +35,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
_apiSubscription = ApiService.instance.messages.listen((message) { _apiSubscription = ApiService.instance.messages.listen((message) {
if (!mounted) return; if (!mounted) return;
if (message['type'] == 'contact_found') { if (message['type'] == 'contact_found') {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
@@ -48,22 +46,24 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
if (contactData != null) { if (contactData != null) {
_foundContact = Contact.fromJson(contactData); _foundContact = Contact.fromJson(contactData);
}
ScaffoldMessenger.of(context).showSnackBar( // Автоматически открываем чат с найденным контактом
SnackBar( _openChatWithContact(_foundContact!);
content: const Text('Контакт найден!'), } else {
backgroundColor: Colors.green, ScaffoldMessenger.of(context).showSnackBar(
shape: RoundedRectangleBorder( SnackBar(
borderRadius: BorderRadius.circular(12), content: const Text('Контакт найден!'),
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(10),
), ),
behavior: SnackBarBehavior.floating, );
margin: const EdgeInsets.all(10), }
),
);
} }
if (message['type'] == 'contact_not_found') { if (message['type'] == 'contact_not_found') {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
@@ -118,7 +118,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
return; return;
} }
if (!phone.startsWith('+') || phone.length < 10) { if (!phone.startsWith('+') || phone.length < 10) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -160,20 +159,146 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
} }
} }
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: const Text('Не удалось найти чат с этим контактом'),
backgroundColor: Colors.orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
behavior: SnackBarBehavior.floating,
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() { void _startChat() {
if (_foundContact != null) { if (_foundContact != null) {
_openChatWithContact(_foundContact!);
}
}
ScaffoldMessenger.of(context).showSnackBar( Future<void> _startChatAlternative() async {
SnackBar( if (_foundContact == null) return;
content: Text('Создание чата с ${_foundContact!.name}'),
backgroundColor: Colors.blue, try {
shape: RoundedRectangleBorder( setState(() {
borderRadius: BorderRadius.circular(12), _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('Понятно'),
),
],
), ),
behavior: SnackBarBehavior.floating, );
margin: const EdgeInsets.all(10), }
), } 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -230,7 +354,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Номер телефона', 'Номер телефона',
style: Theme.of( style: Theme.of(
@@ -322,7 +445,6 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
), ),
), ),
if (_foundContact != null) ...[ if (_foundContact != null) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Container( 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) ...[ if (_errorMessage != null) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Container( Container(

View File

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

View File

@@ -208,7 +208,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
) )
: null, : null,
), ),
body: _buildSettingsContent(), body: SafeArea(
child: _buildSettingsContent(),
),
); );
} }

View File

@@ -21,7 +21,7 @@ import 'package:gwid/api/api_service.dart';
import 'dart:async'; import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
import 'package:gwid/utils/full_screen_video_player.dart'; import 'package:gwid/widgets/full_screen_video_player.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:gwid/services/cache_service.dart'; import 'package:gwid/services/cache_service.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -106,7 +106,9 @@ dependencies:
es_compression: ^2.0.14 es_compression: ^2.0.14
msgpack_dart: ^1.0.1 msgpack_dart: ^1.0.1
ffi: ^2.1.0
disable_battery_optimization: ^1.1.2 disable_battery_optimization: ^1.1.2