From 91bdfea222e3133b4d32ff0e8c803953765236a1 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 13:36:30 +0300 Subject: [PATCH] feat(auth): server endpoint in bottom sheet on login --- lib/backend/api.dart | 3 +- lib/core/config/config.dart | 22 +- lib/frontend/screens/auth/login_screen.dart | 39 +++- .../screens/auth/server_settings_sheet.dart | 215 ++++++++++++++++++ lib/l10n/app_en.arb | 9 + lib/l10n/app_localizations.dart | 54 +++++ lib/l10n/app_localizations_en.dart | 27 +++ lib/l10n/app_localizations_ru.dart | 28 +++ lib/l10n/app_ru.arb | 9 + pubspec.lock | 16 +- 10 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 lib/frontend/screens/auth/server_settings_sheet.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 402cf92..7751155 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -70,7 +70,8 @@ class Api { }); try { - await _connection.connect(ServerConfig.host, ServerConfig.port); + final endpoint = await ServerConfig.loadEndpoint(); + await _connection.connect(endpoint.host, endpoint.port); } catch (e) { logger.e('Не удалось подключиться: $e'); _cleanup(); diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart index 4ef18b3..c0c97cc 100644 --- a/lib/core/config/config.dart +++ b/lib/core/config/config.dart @@ -1,7 +1,25 @@ +import 'package:shared_preferences/shared_preferences.dart'; + abstract class ServerConfig { - static const String host = 'api.oneme.ru'; - static const int port = 443; + static const String defaultHost = 'api.oneme.ru'; + static const int defaultPort = 443; + static const String prefHostKey = 'server_host_override'; + static const String prefPortKey = 'server_port_override'; static const Duration pingInterval = Duration(seconds: 30); static const Duration requestTimeout = Duration(seconds: 30); static const int maxReconnectAttempts = 50; + + static Future<({String host, int port})> loadEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + final rawHost = prefs.getString(prefHostKey); + final rawPort = prefs.getInt(prefPortKey); + final host = (rawHost != null && rawHost.trim().isNotEmpty) + ? rawHost.trim() + : defaultHost; + var port = defaultPort; + if (rawPort != null && rawPort >= 1 && rawPort <= 65535) { + port = rawPort; + } + return (host: host, port: port); + } } diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 579f250..a531b1d 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 'server_settings_sheet.dart'; import 'spoff_redacted_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../../main.dart'; @@ -448,6 +449,23 @@ class _LoginScreenState extends State { _showPhoneConfirmationDialog(_phoneController.text); } + void _showServerSettingsSheet(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) { + return SafeArea( + child: const ServerSettingsSheet(), + ); + }, + ); + } + void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -457,7 +475,7 @@ class _LoginScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - builder: (context) { + builder: (sheetContext) { return SafeArea( child: Padding( padding: const EdgeInsets.symmetric( @@ -478,7 +496,7 @@ class _LoginScreenState extends State { ), ), onTap: () { - Navigator.pop(context); + Navigator.pop(sheetContext); Navigator.push( context, MaterialPageRoute( @@ -498,7 +516,22 @@ class _LoginScreenState extends State { ), ), onTap: () { - Navigator.pop(context); + Navigator.pop(sheetContext); + }, + ), + ListTile( + leading: Icon(Symbols.dns, color: cs.onSurface), + title: Text( + l10n.loginChangeServer, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + _showServerSettingsSheet(context); }, ), ], diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart new file mode 100644 index 0000000..9c85651 --- /dev/null +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/core/config/config.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +class ServerSettingsSheet extends StatefulWidget { + const ServerSettingsSheet({super.key}); + + @override + State createState() => _ServerSettingsSheetState(); +} + +class _ServerSettingsSheetState extends State { + final TextEditingController _hostController = TextEditingController( + text: ServerConfig.defaultHost, + ); + final TextEditingController _portController = TextEditingController( + text: '${ServerConfig.defaultPort}', + ); + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final endpoint = await ServerConfig.loadEndpoint(); + if (!mounted) return; + setState(() { + _hostController.text = endpoint.host; + _portController.text = '${endpoint.port}'; + }); + } + + Future _apply(AppLocalizations l10n) async { + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()); + if (host.isEmpty || port == null || port < 1 || port > 65535) { + showCustomNotification(context, l10n.serverInvalidHostOrPort); + return; + } + setState(() => _busy = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ServerConfig.prefHostKey, host); + await prefs.setInt(ServerConfig.prefPortKey, port); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.serverSettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _resetToDefault(AppLocalizations l10n) async { + setState(() => _busy = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(ServerConfig.prefHostKey); + await prefs.remove(ServerConfig.prefPortKey); + _hostController.text = ServerConfig.defaultHost; + _portController.text = '${ServerConfig.defaultPort}'; + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.serverSettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + l10n.serverSettingsTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + _buildTextField( + controller: _hostController, + label: l10n.serverHostLabel, + hintText: ServerConfig.defaultHost, + cs: cs, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _portController, + label: l10n.serverPortLabel, + hintText: '${ServerConfig.defaultPort}', + cs: cs, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : () => _apply(l10n), + child: Text(l10n.serverApply), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _busy ? null : () => _resetToDefault(l10n), + child: Text(l10n.serverUseDefault), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + String? hintText, + TextInputType? keyboardType, + List? inputFormatters, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + enabled: !_busy, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 15, + ), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aa424d8..57a5c1c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15,6 +15,15 @@ "loginReadTermsNotification": "Please read the terms of use first", "loginSpoofRedacted": "Spoof redaction", "loginProxy": "Proxy", + "loginChangeServer": "Change server", + "serverSettingsTitle": "Server", + "serverHostLabel": "Host", + "serverPortLabel": "Port", + "serverApply": "Apply and reconnect", + "serverUseDefault": "Reset to default", + "serverInvalidHostOrPort": "Enter a valid host and port (1–65535)", + "serverSettingsSaved": "Server settings applied", + "serverReconnectFailed": "Could not connect to the server", "loginSignInWithQr": "Sign in with QR code", "loginSignInWithToken": "Sign in with token", "loginSignInWithSessionFile": "Sign in with session file", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b0614d5..605d726 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -188,6 +188,60 @@ abstract class AppLocalizations { /// **'Proxy'** String get loginProxy; + /// No description provided for @loginChangeServer. + /// + /// In en, this message translates to: + /// **'Change server'** + String get loginChangeServer; + + /// No description provided for @serverSettingsTitle. + /// + /// In en, this message translates to: + /// **'Server'** + String get serverSettingsTitle; + + /// No description provided for @serverHostLabel. + /// + /// In en, this message translates to: + /// **'Host'** + String get serverHostLabel; + + /// No description provided for @serverPortLabel. + /// + /// In en, this message translates to: + /// **'Port'** + String get serverPortLabel; + + /// No description provided for @serverApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get serverApply; + + /// No description provided for @serverUseDefault. + /// + /// In en, this message translates to: + /// **'Reset to default'** + String get serverUseDefault; + + /// No description provided for @serverInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid host and port (1–65535)'** + String get serverInvalidHostOrPort; + + /// No description provided for @serverSettingsSaved. + /// + /// In en, this message translates to: + /// **'Server settings applied'** + String get serverSettingsSaved; + + /// No description provided for @serverReconnectFailed. + /// + /// In en, this message translates to: + /// **'Could not connect to the server'** + String get serverReconnectFailed; + /// No description provided for @loginSignInWithQr. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5ddbe9b..32b6401 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginProxy => 'Proxy'; + @override + String get loginChangeServer => 'Change server'; + + @override + String get serverSettingsTitle => 'Server'; + + @override + String get serverHostLabel => 'Host'; + + @override + String get serverPortLabel => 'Port'; + + @override + String get serverApply => 'Apply and reconnect'; + + @override + String get serverUseDefault => 'Reset to default'; + + @override + String get serverInvalidHostOrPort => 'Enter a valid host and port (1–65535)'; + + @override + String get serverSettingsSaved => 'Server settings applied'; + + @override + String get serverReconnectFailed => 'Could not connect to the server'; + @override String get loginSignInWithQr => 'Sign in with QR code'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e63e96d..f1f7049 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -55,6 +55,34 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginProxy => 'Прокси'; + @override + String get loginChangeServer => 'Смена сервера'; + + @override + String get serverSettingsTitle => 'Сервер'; + + @override + String get serverHostLabel => 'Хост'; + + @override + String get serverPortLabel => 'Порт'; + + @override + String get serverApply => 'Применить и переподключиться'; + + @override + String get serverUseDefault => 'Сбросить к умолчанию'; + + @override + String get serverInvalidHostOrPort => + 'Укажите корректный хост и порт (1–65535)'; + + @override + String get serverSettingsSaved => 'Настройки сервера применены'; + + @override + String get serverReconnectFailed => 'Не удалось подключиться к серверу'; + @override String get loginSignInWithQr => 'По QR code'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a53ed02..fdf86aa 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -15,6 +15,15 @@ "loginReadTermsNotification": "Сначала прочитайте условия использования", "loginSpoofRedacted": "Подделка спуфа", "loginProxy": "Прокси", + "loginChangeServer": "Смена сервера", + "serverSettingsTitle": "Сервер", + "serverHostLabel": "Хост", + "serverPortLabel": "Порт", + "serverApply": "Применить и переподключиться", + "serverUseDefault": "Сбросить к умолчанию", + "serverInvalidHostOrPort": "Укажите корректный хост и порт (1–65535)", + "serverSettingsSaved": "Настройки сервера применены", + "serverReconnectFailed": "Не удалось подключиться к серверу", "loginSignInWithQr": "По QR code", "loginSignInWithToken": "По токену", "loginSignInWithSessionFile": "По файлу сессии", diff --git a/pubspec.lock b/pubspec.lock index 1f7d572..b09c9ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: