File picker permissions and header rename. Also merged jija shit
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
753
lib/api/api_registration_service.dart
Normal file
753
lib/api/api_registration_service.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
2
lib/consts.dart
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Датафайл с константами, полезно при изменении версии например
|
||||||
|
const version = "0.3.0";
|
||||||
@@ -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 {
|
||||||
@@ -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() {
|
void _showSearchFilters() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -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,6 +202,14 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _initializeMaskFormatter() {
|
void _initializeMaskFormatter() {
|
||||||
|
if (_selectedCountry.mask.isEmpty) {
|
||||||
|
// Для "Свой префикс" - без маски, только цифры
|
||||||
|
_maskFormatter = MaskTextInputFormatter(
|
||||||
|
mask: '',
|
||||||
|
filter: {"#": RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
final mask = _selectedCountry.mask
|
final mask = _selectedCountry.mask
|
||||||
.replaceFirst(RegExp(r'^\+\d+\s?'), '')
|
.replaceFirst(RegExp(r'^\+\d+\s?'), '')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -150,6 +219,7 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
|
|||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onPhoneChanged() {
|
void _onPhoneChanged() {
|
||||||
final text = _phoneController.text;
|
final text = _phoneController.text;
|
||||||
@@ -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,16 +265,77 @@ 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) {
|
||||||
|
// Если выбран "Свой префикс", показываем диалог для ввода префикса
|
||||||
|
if (country.mask.isEmpty) {
|
||||||
|
final prefix = await _showCustomPrefixDialog();
|
||||||
|
if (prefix == null || prefix.isEmpty) {
|
||||||
|
return; // Отменено
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCountry = country;
|
_selectedCountry = country;
|
||||||
|
_customPrefix = prefix.startsWith('+') ? prefix : '+$prefix';
|
||||||
|
_phoneController.clear();
|
||||||
|
_initializeMaskFormatter();
|
||||||
|
_isButtonEnabled = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_selectedCountry = country;
|
||||||
|
_customPrefix = '';
|
||||||
_phoneController.clear();
|
_phoneController.clear();
|
||||||
_initializeMaskFormatter();
|
_initializeMaskFormatter();
|
||||||
_isButtonEnabled = false;
|
_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();
|
||||||
@@ -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: [
|
||||||
|
if (country.flag.isNotEmpty) ...[
|
||||||
Text(country.flag, style: textTheme.titleMedium),
|
Text(country.flag, style: textTheme.titleMedium),
|
||||||
const SizedBox(width: 8),
|
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,
|
||||||
|
|||||||
542
lib/screens/registration_screen.dart
Normal file
542
lib/screens/registration_screen.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +46,10 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
|
|||||||
|
|
||||||
if (contactData != null) {
|
if (contactData != null) {
|
||||||
_foundContact = Contact.fromJson(contactData);
|
_foundContact = Contact.fromJson(contactData);
|
||||||
}
|
|
||||||
|
|
||||||
|
// Автоматически открываем чат с найденным контактом
|
||||||
|
_openChatWithContact(_foundContact!);
|
||||||
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Text('Контакт найден!'),
|
content: const Text('Контакт найден!'),
|
||||||
@@ -62,7 +62,7 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message['type'] == 'contact_not_found') {
|
if (message['type'] == 'contact_not_found') {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -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,13 +159,21 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startChat() {
|
Future<void> _openChatWithContact(Contact contact) async {
|
||||||
if (_foundContact != null) {
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Создание чата с ${_foundContact!.name}'),
|
content: const Text('Не удалось найти чат с этим контактом'),
|
||||||
backgroundColor: Colors.blue,
|
backgroundColor: Colors.orange,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -174,6 +181,124 @@ class _SearchContactScreenState extends State<SearchContactScreen> {
|
|||||||
margin: const EdgeInsets.all(10),
|
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(
|
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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
body: _buildSettingsContent(),
|
body: SafeArea(
|
||||||
|
child: _buildSettingsContent(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
935
lib/widgets/full_screen_video_player.dart
Normal file
935
lib/widgets/full_screen_video_player.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
1551
pubspec.lock
1551
pubspec.lock
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,8 @@ dependencies:
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
flutter_highlight: ^0.7.0
|
flutter_highlight: ^0.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user