Files
IKAR/lib/frontend/debug/server_section.dart
T

278 lines
8.4 KiB
Dart

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<void> load() async {
_endpoint = await ServerConfig.loadEndpoint();
_notify();
}
Future<bool?> 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<bool?> reset() => _reconnect((
host: ServerConfig.defaultHost,
port: ServerConfig.defaultPort,
trustMincifryCa: ServerConfig.defaultTrustMincifryCa,
), ServerConfig.resetEndpoint);
Future<bool?> _reconnect(
ServerEndpoint next,
Future<void> 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<void> _pickHost(BuildContext context) async {
final host = await showModalBottomSheet<String>(
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<void> _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<void> _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<void> _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,
);
}
}