feat(network): SOCKS5 and HTTP(S) proxy support
This commit is contained in:
@@ -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<LoginScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showProxySettingsSheet(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
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<LoginScreen> {
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showProxySettingsSheet(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
|
||||
@@ -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<ProxySettingsSheet> createState() => _ProxySettingsSheetState();
|
||||
}
|
||||
|
||||
class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
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<void> _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<void> _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<void> _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<TextInputFormatter>? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsTab> {
|
||||
icon: Symbols.notifications_active,
|
||||
label: 'Уведомления и звук',
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.vpn_lock,
|
||||
label: 'Прокси',
|
||||
onTap: () {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
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: 'Подделка данных',
|
||||
|
||||
Reference in New Issue
Block a user