diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart new file mode 100644 index 0000000..3428fd8 --- /dev/null +++ b/lib/core/config/proxy_config.dart @@ -0,0 +1,77 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +enum ProxyType { none, socks5, httpConnect } + +class ProxySettings { + final ProxyType type; + final String host; + final int port; + final String? username; + final String? password; + + const ProxySettings({ + this.type = ProxyType.none, + this.host = '', + this.port = 1080, + this.username, + this.password, + }); + + bool get isEnabled => type != ProxyType.none && host.isNotEmpty; + + bool get hasCredentials => + username != null && + username!.isNotEmpty && + password != null && + password!.isNotEmpty; +} + +abstract class ProxyConfig { + static const String _prefType = 'proxy_type'; + static const String _prefHost = 'proxy_host'; + static const String _prefPort = 'proxy_port'; + static const String _prefUsername = 'proxy_username'; + static const String _prefPassword = 'proxy_password'; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final typeIndex = prefs.getInt(_prefType) ?? 0; + final host = prefs.getString(_prefHost) ?? ''; + final port = prefs.getInt(_prefPort) ?? 1080; + final username = prefs.getString(_prefUsername); + final password = prefs.getString(_prefPassword); + return ProxySettings( + type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], + host: host, + port: port, + username: username, + password: password, + ); + } + + static Future save(ProxySettings settings) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_prefType, settings.type.index); + await prefs.setString(_prefHost, settings.host); + await prefs.setInt(_prefPort, settings.port); + if (settings.username != null) { + await prefs.setString(_prefUsername, settings.username!); + } else { + await prefs.remove(_prefUsername); + } + if (settings.password != null) { + await prefs.setString(_prefPassword, settings.password!); + } else { + await prefs.remove(_prefPassword); + } + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefType); + await prefs.remove(_prefHost); + await prefs.remove(_prefPort); + await prefs.remove(_prefUsername); + await prefs.remove(_prefPassword); + } +} diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 16c4444..15c3a53 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -2,14 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import '../config/proxy_config.dart'; import '../utils/logger.dart'; +import 'proxy_connector.dart'; enum SocketState { disconnected, connecting, connected } /// Обёртка над TCP + TLS сокетом. /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { - SecureSocket? _socket; + RawSecureSocket? _socket; + StreamSubscription? _subscription; SocketState _state = SocketState.disconnected; final _dataController = StreamController.broadcast(); @@ -31,17 +34,39 @@ class Connection { _setState(SocketState.connecting); try { - final socket = await Socket.connect(host, port); - _socket = await SecureSocket.secure( - socket, + final proxySettings = await ProxyConfig.load(); + RawSocket rawSocket; + + if (proxySettings.isEnabled) { + final connector = ProxyConnector(proxySettings); + rawSocket = await connector.connect(host, port); + logger.i('Подключено через прокси ${proxySettings.type.name}'); + } else { + rawSocket = await RawSocket.connect(host, port); + } + + _socket = await RawSecureSocket.secure( + rawSocket, + host: host, onBadCertificate: (_) => true, ); _setState(SocketState.connected); logger.i('Подключено к $host:$port'); - _socket!.listen( - (data) => _dataController.add(Uint8List.fromList(data)), + _subscription = _socket!.listen( + (event) { + if (event == RawSocketEvent.read) { + final data = _socket?.read(); + if (data != null) { + _dataController.add(data); + } + } else if (event == RawSocketEvent.readClosed || + event == RawSocketEvent.closed) { + logger.w('Сокет закрыт сервером'); + disconnect(); + } + }, onError: (Object error) { logger.e('Ошибка сокета: $error'); disconnect(); @@ -62,16 +87,18 @@ class Connection { if (_socket == null || !isConnected) { throw StateError('Нельзя писать: сокет не подключён'); } - _socket!.add(data); + _socket!.write(data); } Future disconnect() async { + _subscription?.cancel(); + _subscription = null; final socket = _socket; _socket = null; if (socket != null) { try { - socket.destroy(); + socket.close(); } catch (_) {} } diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart new file mode 100644 index 0000000..aaac29b --- /dev/null +++ b/lib/core/transport/proxy_connector.dart @@ -0,0 +1,365 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../config/proxy_config.dart'; +import '../utils/logger.dart'; + +/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси. +/// Возвращает [RawSocket], который никогда не слушался — +/// его можно передать в [RawSecureSocket.secure]. +class ProxyConnector { + final ProxySettings settings; + + ProxyConnector(this.settings); + + Future connect(String targetHost, int targetPort) async { + switch (settings.type) { + case ProxyType.socks5: + return _connectSocks5(targetHost, targetPort); + case ProxyType.httpConnect: + return _connectHttpConnect(targetHost, targetPort); + case ProxyType.none: + return RawSocket.connect(targetHost, targetPort); + } + } + + // ── SOCKS5 (RFC 1928) ────────────────────────────────────────────────── + + Future _connectSocks5(String targetHost, int targetPort) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}'); + + final io = _RawSocketIO(proxySocket); + try { + // 1. Greeting + final useAuth = settings.hasCredentials; + if (useAuth) { + await io.write([0x05, 0x02, 0x00, 0x02]); + } else { + await io.write([0x05, 0x01, 0x00]); + } + + var response = await io.readExact(2); + if (response[0] != 0x05) { + throw SocketException( + 'SOCKS5: неверная версия протокола: ${response[0]}', + ); + } + + final method = response[1]; + if (method == 0xFF) { + throw SocketException( + 'SOCKS5: сервер отклонил все методы аутентификации', + ); + } + + // 2. Аутентификация (RFC 1929) + if (method == 0x02) { + if (!useAuth) { + throw SocketException('SOCKS5: прокси требует аутентификацию'); + } + final usernameBytes = utf8.encode(settings.username!); + final passwordBytes = utf8.encode(settings.password!); + final authPacket = BytesBuilder() + ..addByte(0x01) + ..addByte(usernameBytes.length) + ..add(usernameBytes) + ..addByte(passwordBytes.length) + ..add(passwordBytes); + await io.write(authPacket.toBytes()); + + final authResponse = await io.readExact(2); + if (authResponse[1] != 0x00) { + throw SocketException('SOCKS5: аутентификация не пройдена'); + } + logger.i('SOCKS5: аутентификация пройдена'); + } + + // 3. Connect request + final hostBytes = utf8.encode(targetHost); + final connectPacket = BytesBuilder() + ..addByte(0x05) // VER + ..addByte(0x01) // CMD: CONNECT + ..addByte(0x00) // RSV + ..addByte(0x03) // ATYP: domain + ..addByte(hostBytes.length) + ..add(hostBytes) + ..addByte((targetPort >> 8) & 0xFF) + ..addByte(targetPort & 0xFF); + await io.write(connectPacket.toBytes()); + + // 4. Reply + final reply = await io.readExact(4); + if (reply[0] != 0x05) { + throw SocketException('SOCKS5: неверная версия в ответе'); + } + if (reply[1] != 0x00) { + throw SocketException( + 'SOCKS5: ошибка подключения, код: ${reply[1]}', + ); + } + + // Пропускаем bind address + switch (reply[3]) { + case 0x01: + await io.readExact(4 + 2); + break; + case 0x03: + final lenBuf = await io.readExact(1); + await io.readExact(lenBuf[0] + 2); + break; + case 0x04: + await io.readExact(16 + 2); + break; + } + + logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен'); + + // Создаём локальную пару и проксируем данные + return _bridgeToFreshSocket(proxySocket, io); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } + } + + // ── HTTP CONNECT ──────────────────────────────────────────────────────── + + Future _connectHttpConnect( + String targetHost, + int targetPort, + ) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i( + 'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}', + ); + + final io = _RawSocketIO(proxySocket); + try { + final request = StringBuffer() + ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') + ..write('Host: $targetHost:$targetPort\r\n'); + + if (settings.hasCredentials) { + final credentials = base64Encode( + utf8.encode('${settings.username}:${settings.password}'), + ); + request.write('Proxy-Authorization: Basic $credentials\r\n'); + } + request.write('\r\n'); + + await io.write(utf8.encode(request.toString())); + + // Читаем HTTP-ответ до \r\n\r\n + final headerBytes = []; + while (true) { + final byte = await io.readExact(1); + headerBytes.add(byte[0]); + if (headerBytes.length >= 4 && + headerBytes[headerBytes.length - 4] == 0x0D && + headerBytes[headerBytes.length - 3] == 0x0A && + headerBytes[headerBytes.length - 2] == 0x0D && + headerBytes[headerBytes.length - 1] == 0x0A) { + break; + } + if (headerBytes.length > 8192) { + throw SocketException( + 'HTTP CONNECT: заголовок ответа слишком большой', + ); + } + } + + final responseStr = utf8.decode(headerBytes, allowMalformed: true); + final statusLine = responseStr.split('\r\n').first; + final parts = statusLine.split(' '); + final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0; + if (statusCode != 200) { + throw SocketException( + 'HTTP CONNECT: прокси вернул статус $statusCode', + ); + } + + logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен'); + return _bridgeToFreshSocket(proxySocket, io); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } + } + + // ── Мост: создаём свежий сокет и проксируем через loopback ───────────── + + /// После handshake proxy-сокет уже прослушан (single-subscription). + /// Создаём пару локальных сокетов через loopback и проксируем данные + /// между прокси-сокетом и одним концом. Второй конец возвращаем — + /// он «свежий» и его можно передать в [RawSecureSocket.secure]. + Future _bridgeToFreshSocket( + RawSocket proxySocket, + _RawSocketIO io, + ) async { + final server = await RawServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final clientSide = await RawSocket.connect( + InternetAddress.loopbackIPv4, + server.port, + ); + final serverSide = await server.first; + await server.close(); + + // proxy → local (через уже имеющуюся подписку _RawSocketIO) + io.onData = (data) { + serverSide.write(data); + }; + io.onClosed = () { + serverSide.shutdown(SocketDirection.send); + }; + + // local → proxy + serverSide.listen((event) { + if (event == RawSocketEvent.read) { + final data = serverSide.read(); + if (data != null) proxySocket.write(data); + } else if (event == RawSocketEvent.readClosed || + event == RawSocketEvent.closed) { + proxySocket.shutdown(SocketDirection.send); + } + }); + + // Сливаем данные, буферизованные во время handshake + io.flushBuffered(); + + logger.i('Прокси-мост через loopback создан'); + return clientSide; + } +} + +/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения. +/// +/// После handshake переключается в режим моста: +/// данные из proxy-сокета пересылаются через [onData] в loopback-пару. +class _RawSocketIO { + final RawSocket _socket; + late final StreamSubscription _sub; + + final _readBuffer = []; + Completer? _readWaiter; + Completer? _writeWaiter; + bool _closed = false; + Object? _error; + + /// Коллбэк для данных в режиме моста. + void Function(Uint8List data)? onData; + + /// Коллбэк закрытия в режиме моста. + void Function()? onClosed; + + _RawSocketIO(this._socket) { + _sub = _socket.listen( + _onEvent, + onError: (Object err) { + _error = err; + _closed = true; + _readWaiter?.completeError(err); + _readWaiter = null; + _writeWaiter?.completeError(err); + _writeWaiter = null; + }, + ); + } + + void _onEvent(RawSocketEvent event) { + switch (event) { + case RawSocketEvent.read: + final data = _socket.read(); + if (data != null) { + if (onData != null) { + // Режим моста — пересылаем напрямую + onData!(data); + } else { + // Режим handshake — буферизуем + _readBuffer.addAll(data); + _readWaiter?.complete(); + _readWaiter = null; + } + } + break; + case RawSocketEvent.write: + _writeWaiter?.complete(); + _writeWaiter = null; + break; + case RawSocketEvent.readClosed: + case RawSocketEvent.closed: + _closed = true; + onClosed?.call(); + _readWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _readWaiter = null; + _writeWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _writeWaiter = null; + break; + } + } + + /// Читает ровно [count] байт. + Future readExact(int count) async { + while (_readBuffer.length < count) { + if (_error != null) throw _error!; + if (_closed) { + throw SocketException( + 'Соединение закрыто ' + '(ожидали $count байт, получили ${_readBuffer.length})', + ); + } + _readWaiter = Completer(); + await _readWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при чтении от прокси'), + ); + } + final result = Uint8List.fromList(_readBuffer.sublist(0, count)); + _readBuffer.removeRange(0, count); + return result; + } + + /// Записывает все байты. + Future write(List data) async { + var offset = 0; + while (offset < data.length) { + if (_error != null) throw _error!; + if (_closed) throw SocketException('Соединение закрыто при записи'); + final written = _socket.write(data, offset); + if (written > 0) { + offset += written; + } else { + _writeWaiter = Completer(); + await _writeWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при записи в прокси'), + ); + } + } + } + + /// Пересылает данные, оставшиеся в буфере после handshake, в мост. + void flushBuffered() { + if (_readBuffer.isNotEmpty && onData != null) { + onData!(Uint8List.fromList(_readBuffer)); + _readBuffer.clear(); + } + } + + void dispose() { + _sub.cancel(); + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index e182159..d5b7784 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; +import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import 'spoff_redacted_screen.dart'; import '../../widgets/custom_notification.dart'; @@ -470,6 +471,23 @@ class _LoginScreenState extends State { ); } + void _showProxySettingsSheet(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) { + return SafeArea( + child: const ProxySettingsSheet(), + ); + }, + ); + } + void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -521,6 +539,7 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(sheetContext); + _showProxySettingsSheet(context); }, ), ListTile( diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart new file mode 100644 index 0000000..8553ab1 --- /dev/null +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/core/config/proxy_config.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +class ProxySettingsSheet extends StatefulWidget { + const ProxySettingsSheet({super.key}); + + @override + State createState() => _ProxySettingsSheetState(); +} + +class _ProxySettingsSheetState extends State { + final _hostController = TextEditingController(); + final _portController = TextEditingController(text: '1080'); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + ProxyType _selectedType = ProxyType.none; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final settings = await ProxyConfig.load(); + if (!mounted) return; + setState(() { + _selectedType = settings.type; + _hostController.text = settings.host; + _portController.text = '${settings.port}'; + _usernameController.text = settings.username ?? ''; + _passwordController.text = settings.password ?? ''; + }); + } + + Future _apply(AppLocalizations l10n) async { + if (_selectedType == ProxyType.none) { + return _disable(l10n); + } + + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()); + if (host.isEmpty || port == null || port < 1 || port > 65535) { + showCustomNotification(context, l10n.proxyInvalidHostOrPort); + return; + } + + setState(() => _busy = true); + try { + final username = _usernameController.text.trim(); + final password = _passwordController.text.trim(); + await ProxyConfig.save(ProxySettings( + type: _selectedType, + host: host, + port: port, + username: username.isNotEmpty ? username : null, + password: password.isNotEmpty ? password : null, + )); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.proxySettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _disable(AppLocalizations l10n) async { + setState(() => _busy = true); + try { + await ProxyConfig.clear(); + setState(() => _selectedType = ProxyType.none); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.proxySettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + final isActive = _selectedType != ProxyType.none; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + l10n.proxySettingsTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + + // Proxy type selector + _buildTypeSelector(cs, l10n), + const SizedBox(height: 16), + + // Fields shown only when proxy is enabled + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: isActive + ? Column( + children: [ + _buildTextField( + controller: _hostController, + label: l10n.proxyHostLabel, + hintText: '127.0.0.1', + cs: cs, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _portController, + label: l10n.proxyPortLabel, + hintText: _selectedType == ProxyType.socks5 + ? '1080' + : '8080', + cs: cs, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + const SizedBox(height: 16), + _buildTextField( + controller: _usernameController, + label: l10n.proxyUsernameLabel, + cs: cs, + keyboardType: TextInputType.text, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _passwordController, + label: l10n.proxyPasswordLabel, + cs: cs, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + ), + const SizedBox(height: 8), + ], + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : () => _apply(l10n), + child: Text( + isActive ? l10n.proxyApply : l10n.proxyDisable, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) { + final labels = { + ProxyType.none: l10n.proxyTypeNone, + ProxyType.socks5: l10n.proxyTypeSocks5, + ProxyType.httpConnect: l10n.proxyTypeHttp, + }; + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: ProxyType.values.map((type) { + final selected = _selectedType == type; + return Expanded( + child: GestureDetector( + onTap: _busy ? null : () => setState(() => _selectedType = type), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: selected ? cs.primary : Colors.transparent, + borderRadius: BorderRadius.circular(9), + ), + alignment: Alignment.center, + child: Text( + labels[type]!, + style: GoogleFonts.inter( + color: selected ? cs.onPrimary : cs.onSurfaceVariant, + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + String? hintText, + TextInputType? keyboardType, + List? inputFormatters, + bool obscureText = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + enabled: !_busy, + obscureText: obscureText, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 15, + ), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 4a37280..ddaba8e 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'security_screen.dart'; @@ -118,6 +119,28 @@ class _SettingsTabState extends State { icon: Symbols.notifications_active, label: 'Уведомления и звук', ), + _SettingsItem( + icon: Symbols.vpn_lock, + label: 'Прокси', + onTap: () { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + builder: (_) { + return SafeArea( + child: const ProxySettingsSheet(), + ); + }, + ); + }, + ), _SettingsItem( icon: Symbols.shield_lock, label: 'Подделка данных', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3d832a0..ae9b610 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -41,5 +41,18 @@ } }, "codeResendSms": "Resend code via SMS", - "codeError2faMissing": "Error: missing data for 2FA" + "codeError2faMissing": "Error: missing data for 2FA", + + "proxySettingsTitle": "Proxy", + "proxyTypeNone": "Disabled", + "proxyTypeSocks5": "SOCKS5", + "proxyTypeHttp": "HTTP(S)", + "proxyHostLabel": "Proxy host", + "proxyPortLabel": "Proxy port", + "proxyUsernameLabel": "Username (optional)", + "proxyPasswordLabel": "Password (optional)", + "proxyApply": "Apply and reconnect", + "proxyDisable": "Disable proxy", + "proxySettingsSaved": "Proxy settings applied", + "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 05cc593..c029ed3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -307,6 +307,78 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Error: missing data for 2FA'** String get codeError2faMissing; + + /// No description provided for @proxySettingsTitle. + /// + /// In en, this message translates to: + /// **'Proxy'** + String get proxySettingsTitle; + + /// No description provided for @proxyTypeNone. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get proxyTypeNone; + + /// No description provided for @proxyTypeSocks5. + /// + /// In en, this message translates to: + /// **'SOCKS5'** + String get proxyTypeSocks5; + + /// No description provided for @proxyTypeHttp. + /// + /// In en, this message translates to: + /// **'HTTP(S)'** + String get proxyTypeHttp; + + /// No description provided for @proxyHostLabel. + /// + /// In en, this message translates to: + /// **'Proxy host'** + String get proxyHostLabel; + + /// No description provided for @proxyPortLabel. + /// + /// In en, this message translates to: + /// **'Proxy port'** + String get proxyPortLabel; + + /// No description provided for @proxyUsernameLabel. + /// + /// In en, this message translates to: + /// **'Username (optional)'** + String get proxyUsernameLabel; + + /// No description provided for @proxyPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password (optional)'** + String get proxyPasswordLabel; + + /// No description provided for @proxyApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get proxyApply; + + /// No description provided for @proxyDisable. + /// + /// In en, this message translates to: + /// **'Disable proxy'** + String get proxyDisable; + + /// No description provided for @proxySettingsSaved. + /// + /// In en, this message translates to: + /// **'Proxy settings applied'** + String get proxySettingsSaved; + + /// No description provided for @proxyInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid proxy host and port (1–65535)'** + String get proxyInvalidHostOrPort; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index df7fda7..f7bad3e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -116,4 +116,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get codeError2faMissing => 'Error: missing data for 2FA'; + + @override + String get proxySettingsTitle => 'Proxy'; + + @override + String get proxyTypeNone => 'Disabled'; + + @override + String get proxyTypeSocks5 => 'SOCKS5'; + + @override + String get proxyTypeHttp => 'HTTP(S)'; + + @override + String get proxyHostLabel => 'Proxy host'; + + @override + String get proxyPortLabel => 'Proxy port'; + + @override + String get proxyUsernameLabel => 'Username (optional)'; + + @override + String get proxyPasswordLabel => 'Password (optional)'; + + @override + String get proxyApply => 'Apply and reconnect'; + + @override + String get proxyDisable => 'Disable proxy'; + + @override + String get proxySettingsSaved => 'Proxy settings applied'; + + @override + String get proxyInvalidHostOrPort => + 'Enter a valid proxy host and port (1–65535)'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6a92039..0524bca 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -118,4 +118,41 @@ class AppLocalizationsRu extends AppLocalizations { @override String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA'; + + @override + String get proxySettingsTitle => 'Прокси'; + + @override + String get proxyTypeNone => 'Выключен'; + + @override + String get proxyTypeSocks5 => 'SOCKS5'; + + @override + String get proxyTypeHttp => 'HTTP(S)'; + + @override + String get proxyHostLabel => 'Хост прокси'; + + @override + String get proxyPortLabel => 'Порт прокси'; + + @override + String get proxyUsernameLabel => 'Логин (необязательно)'; + + @override + String get proxyPasswordLabel => 'Пароль (необязательно)'; + + @override + String get proxyApply => 'Применить и переподключиться'; + + @override + String get proxyDisable => 'Отключить прокси'; + + @override + String get proxySettingsSaved => 'Настройки прокси применены'; + + @override + String get proxyInvalidHostOrPort => + 'Укажите корректный хост и порт прокси (1–65535)'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1d92905..babcc9e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -41,5 +41,18 @@ } }, "codeResendSms": "Отправить код по SMS", - "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA" + "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA", + + "proxySettingsTitle": "Прокси", + "proxyTypeNone": "Выключен", + "proxyTypeSocks5": "SOCKS5", + "proxyTypeHttp": "HTTP(S)", + "proxyHostLabel": "Хост прокси", + "proxyPortLabel": "Порт прокси", + "proxyUsernameLabel": "Логин (необязательно)", + "proxyPasswordLabel": "Пароль (необязательно)", + "proxyApply": "Применить и переподключиться", + "proxyDisable": "Отключить прокси", + "proxySettingsSaved": "Настройки прокси применены", + "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)" }