import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../core/config/config.dart'; import '../../main.dart'; import '../widgets/custom_notification.dart'; import '../widgets/prompt_dialog.dart'; import '../widgets/sheet_helpers.dart'; import '../widgets/small_spinner.dart'; import 'dev_menu_widgets.dart'; typedef ServerEndpoint = ({String host, int port, bool trustMincifryCa}); class DebugServerController extends ChangeNotifier { ServerEndpoint _endpoint = ( host: ServerConfig.defaultHost, port: ServerConfig.defaultPort, trustMincifryCa: ServerConfig.defaultTrustMincifryCa, ); bool _busy = false; bool _disposed = false; ServerEndpoint get endpoint => _endpoint; bool get busy => _busy; bool get isDefault => _endpoint.host == ServerConfig.defaultHost && _endpoint.port == ServerConfig.defaultPort && _endpoint.trustMincifryCa == ServerConfig.defaultTrustMincifryCa; Future load() async { _endpoint = await ServerConfig.loadEndpoint(); _notify(); } Future apply({String? host, int? port, bool? trustMincifryCa}) { final next = ( host: host ?? _endpoint.host, port: port ?? _endpoint.port, trustMincifryCa: trustMincifryCa ?? _endpoint.trustMincifryCa, ); if (next == _endpoint) return Future.value(null); return _reconnect( next, () => ServerConfig.saveEndpoint( host: next.host, port: next.port, trustMincifryCa: next.trustMincifryCa, ), ); } Future reset() => _reconnect(( host: ServerConfig.defaultHost, port: ServerConfig.defaultPort, trustMincifryCa: ServerConfig.defaultTrustMincifryCa, ), ServerConfig.resetEndpoint); Future _reconnect( ServerEndpoint next, Future Function() persist, ) async { if (_busy) return null; _busy = true; _endpoint = next; _notify(); try { await persist(); return await api.reconnectToEndpoint(); } finally { _busy = false; _notify(); } } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; super.dispose(); } } void reportServerReconnect(BuildContext context, bool? online) { if (online == null) return; showCustomNotification( context, online ? 'Подключено к серверу' : 'Не удалось подключиться к серверу', ); } class DebugServerSection extends StatelessWidget { final DebugServerController controller; const DebugServerSection({super.key, required this.controller}); Future _pickHost(BuildContext context) async { final host = await showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, shape: kSheetShape, isScrollControlled: true, builder: (_) => _HostPickerSheet(current: controller.endpoint.host), ); if (host == null) return; final online = await controller.apply(host: host); if (context.mounted) reportServerReconnect(context, online); } Future _editPort(BuildContext context) async { final raw = await showTextInputDialog( context, title: 'Порт сервера', hint: '${ServerConfig.defaultPort}', initialValue: '${controller.endpoint.port}', confirmLabel: 'Сохранить', keyboardType: TextInputType.number, ); if (raw == null || !context.mounted) return; final port = int.tryParse(raw.trim()); if (!ServerConfig.isValidPort(port)) { showCustomNotification(context, 'Порт должен быть от 1 до 65535'); return; } final online = await controller.apply(port: port); if (context.mounted) reportServerReconnect(context, online); } Future _setTrust(BuildContext context, bool trust) async { final online = await controller.apply(trustMincifryCa: trust); if (context.mounted) reportServerReconnect(context, online); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return ListenableBuilder( listenable: controller, builder: (context, _) { final endpoint = controller.endpoint; final busy = controller.busy; return DevGroup( children: [ DevRow( caption: 'Адрес сервера', title: endpoint.host, onTap: busy ? null : () => _pickHost(context), trailing: busy ? SmallSpinner(size: 20, color: cs.onSurfaceVariant) : Icon(Symbols.chevron_right, color: cs.outline, size: 20), ), DevRow( caption: 'Порт сервера', title: '${endpoint.port}', onTap: busy ? null : () => _editPort(context), ), DevSwitchRow( title: 'Доверять сертификату Минцифры', value: endpoint.trustMincifryCa, onChanged: busy ? null : (v) => _setTrust(context, v), ), ], ); }, ); } } class _HostPickerSheet extends StatelessWidget { final String current; const _HostPickerSheet({required this.current}); Future _enterCustom(BuildContext context) async { final isPreset = ServerConfig.presetHosts.contains(current); final raw = await showTextInputDialog( context, title: 'Свой сервер', hint: 'host.example.com', initialValue: isPreset ? null : current, confirmLabel: 'Подключиться', keyboardType: TextInputType.url, ); if (raw == null || !context.mounted) return; final host = _normalizeHost(raw); if (host.isEmpty) { showCustomNotification(context, 'Введите адрес сервера'); return; } Navigator.pop(context, host); } static String _normalizeHost(String raw) => raw .trim() .replaceFirst(RegExp(r'^[a-z]+://', caseSensitive: false), '') .replaceFirst(RegExp(r'/.*$'), ''); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isCustom = !ServerConfig.presetHosts.contains(current); return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Center( child: SheetGrabber(margin: EdgeInsets.only(bottom: 12)), ), Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), child: Text( 'Сервер для подключения', style: TextStyle( color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w600, ), ), ), for (final host in ServerConfig.presetHosts) _HostOption( title: host, subtitle: host == ServerConfig.defaultHost ? 'По умолчанию' : null, selected: host == current, onTap: () => Navigator.pop(context, host), ), _HostOption( title: 'Свой вариант', subtitle: isCustom ? current : 'Указать адрес вручную', selected: isCustom, onTap: () => _enterCustom(context), ), ], ), ), ); } } class _HostOption extends StatelessWidget { final String title; final String? subtitle; final bool selected; final VoidCallback onTap; const _HostOption({ required this.title, this.subtitle, required this.selected, required this.onTap, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final subtitle = this.subtitle; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20), title: Text(title, style: TextStyle(color: cs.onSurface, fontSize: 16)), subtitle: subtitle == null ? null : Text( subtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), trailing: selected ? Icon(Symbols.check, color: cs.primary) : null, onTap: onTap, ); } }