Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -0,0 +1,340 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_input_consumer.dart';
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart';
/// Outcome of the add-proxy bottom sheet. The sheet itself does not navigate
/// or surface success messages: it pops with one of these so the caller can
/// drive navigation from a stable, non-deactivated context.
sealed class AddProxyAction {
const AddProxyAction();
}
class AddProxyManual extends AddProxyAction {
const AddProxyManual();
}
class AddProxySubscription extends AddProxyAction {
const AddProxySubscription();
}
class AddProxyWithSeed extends AddProxyAction {
final ProxyProfileSeed seed;
const AddProxyWithSeed(this.seed);
}
class AddProxyImported extends AddProxyAction {
final String message;
const AddProxyImported(this.message);
}
/// Guided bottom sheet shown when the user adds a new proxy profile. Each
/// method either pops with an [AddProxyAction] for the caller to apply or
/// stays open so the user can try another method on error.
class AddProxyMethodSheet extends ConsumerWidget {
const AddProxyMethodSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
void popWith(AddProxyAction action) {
if (!context.mounted) return;
Navigator.of(context).pop(action);
}
Future<void> scanQr() async {
final result = await showDialog<Barcode>(
context: context,
builder: (_) => const QrScannerDialog(),
);
final code = result?.code?.trim();
if (code == null || code.isEmpty) return;
if (!context.mounted) return;
final action = await _consumeRawText(context, ref, code);
if (action == null) return;
popWith(action);
}
Future<void> pasteClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text?.trim();
if (text == null || text.isEmpty) {
if (context.mounted) {
showInfoMessage(context, 'Clipboard is empty.');
}
return;
}
if (!context.mounted) return;
final action = await _consumeRawText(context, ref, text);
if (action == null) return;
popWith(action);
}
Future<void> importFromFile() async {
final kind = await showModalBottomSheet<ProxyFileImportKind>(
context: context,
showDragHandle: true,
builder: (_) => const _FileKindPicker(),
);
if (kind == null) return;
if (!context.mounted) return;
final action = await _consumeFile(context, ref, kind);
if (action == null) return;
popWith(action);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
'Add Connection',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Choose how you want to add a proxy profile.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 20),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.25,
children: [
_MethodCard(
icon: Icons.content_paste,
title: 'Clipboard',
subtitle: 'Paste share link or URI',
onTap: pasteClipboard,
isPrimary: true,
),
_MethodCard(
icon: Icons.qr_code_scanner,
title: 'Scan QR',
subtitle: 'From another device',
onTap: scanQr,
),
_MethodCard(
icon: Icons.cloud_download_outlined,
title: 'Subscription',
subtitle: 'Fetch from URL',
onTap: () => popWith(const AddProxySubscription()),
),
_MethodCard(
icon: Icons.upload_file_outlined,
title: 'Import file',
subtitle: '.conf or sing-box JSON',
onTap: importFromFile,
),
],
),
const SizedBox(height: 12),
Center(
child: TextButton.icon(
onPressed: () => popWith(const AddProxyManual()),
icon: const Icon(Icons.edit_note),
label: const Text('Enter manually'),
),
),
],
),
),
);
}
}
class _MethodCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool isPrimary;
const _MethodCard({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.isPrimary = false,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = isPrimary
? scheme.primaryContainer
: scheme.surfaceContainerHigh;
final iconColor = isPrimary ? scheme.onPrimaryContainer : scheme.primary;
final titleColor = isPrimary ? scheme.onPrimaryContainer : scheme.onSurface;
final subtitleColor = isPrimary
? scheme.onPrimaryContainer.withValues(alpha: 0.75)
: scheme.onSurfaceVariant;
return Material(
color: background,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 28, color: iconColor),
const SizedBox(height: 8),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: titleColor,
),
),
const SizedBox(height: 2),
Text(
subtitle,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: subtitleColor),
),
],
),
),
),
);
}
}
class _FileKindPicker extends StatelessWidget {
const _FileKindPicker();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Import from file',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
ListTile(
leading: const Icon(Icons.vpn_lock),
title: const Text('WireGuard config'),
subtitle: const Text('.conf file with [Interface]/[Peer]'),
onTap: () =>
Navigator.of(context).pop(ProxyFileImportKind.wireguardConf),
),
ListTile(
leading: const Icon(Icons.data_object),
title: const Text('Sing-box outbound JSON'),
subtitle: const Text(
'Shadowsocks, Trojan, VMess, VLESS, Hysteria, …',
),
onTap: () => Navigator.of(
context,
).pop(ProxyFileImportKind.singboxOutboundJson),
),
],
),
),
);
}
}
Future<AddProxyAction?> _consumeFile(
BuildContext context,
WidgetRef ref,
ProxyFileImportKind kind,
) async {
final result = await FilePicker.pickFiles(withData: true);
final picked = result?.files.singleOrNull;
if (picked == null) return null;
final outcome = await ref
.read(proxyInputConsumerProvider.notifier)
.consumeFile(kind, picked);
if (!context.mounted) return null;
return _actionFromOutcome(context, outcome);
}
Future<AddProxyAction?> _consumeRawText(
BuildContext context,
WidgetRef ref,
String rawText,
) async {
final outcome = await ref
.read(proxyInputConsumerProvider.notifier)
.consumeRawText(rawText);
if (!context.mounted) return null;
return _actionFromOutcome(context, outcome);
}
AddProxyAction? _actionFromOutcome(
BuildContext context,
ProxyInputOutcome outcome,
) {
switch (outcome) {
case ProxyInputImported(:final created):
return AddProxyImported('Imported profile "${created.name}"');
case ProxyInputSeed(:final seed):
return AddProxyWithSeed(seed);
case ProxyInputError(:final message):
showErrorMessage(context, message);
return null;
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
class CustomOutboundProfileForm extends HookConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const CustomOutboundProfileForm({
super.key,
required this.draftProvider,
required this.draft,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final configController = useTextEditingController(
text: draft.customConfigJson,
);
final secretController = useTextEditingController(
text: draft.customSecretJson,
);
useEffect(() {
if (configController.text != draft.customConfigJson) {
configController.text = draft.customConfigJson;
}
return null;
}, [draft.customConfigJson]);
useEffect(() {
if (secretController.text != draft.customSecretJson) {
secretController.text = draft.customSecretJson;
}
return null;
}, [draft.customSecretJson]);
final notifier = ref.read(draftProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ProfileEditorSection(
title: 'Outbound',
child: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: configController,
minLines: 10,
maxLines: 18,
keyboardType: TextInputType.multiline,
decoration: const InputDecoration(
alignLabelWithHint: true,
labelText: 'Outbound JSON',
helperText: 'Public sing-box outbound object.',
border: OutlineInputBorder(),
),
onChanged: notifier.setCustomConfigJson,
),
),
),
const SizedBox(height: 24),
ProfileEditorSection(
title: 'Secrets',
child: Padding(
padding: const EdgeInsets.all(16),
child: ObscurableTextField(
controller: secretController,
enabled: draft.secretLoaded,
revealedMinLines: 4,
revealedMaxLines: 10,
decoration: const InputDecoration(
alignLabelWithHint: true,
labelText: 'Secret JSON',
helperText:
'Optional values merged into the outbound at runtime.',
border: OutlineInputBorder(),
),
onChanged: notifier.setCustomSecretJson,
),
),
),
],
);
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
/// Per-profile DNS override editor. Keeps the UI surface minimal: a switch to
/// opt in, plus the most common shape (single resolver routed through *this*
/// profile).
class ProfileDnsOverrideSection extends HookConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
const ProfileDnsOverrideSection({super.key, required this.draftProvider});
@override
Widget build(BuildContext context, WidgetRef ref) {
final overrideJson = ref.watch(
draftProvider.select((state) => state.dnsOverrideJson),
);
final initialOverride = useMemoized(() {
if (overrideJson == null || overrideJson.trim().isEmpty) {
return null;
}
try {
return ProxyDnsOverride.fromJson(
jsonDecode(overrideJson) as Map<String, dynamic>,
);
} catch (_) {
return null;
}
}, [overrideJson]);
final enabled = useState(initialOverride != null);
final addressController = useTextEditingController(
text: initialOverride?.remoteServerAddress ?? '',
);
// Reseed controls when the parent passes a new override (e.g. the
// WireGuard form populating DNS from an imported `[Interface] DNS = …`).
useEffect(() {
enabled.value = initialOverride != null;
final next = initialOverride?.remoteServerAddress ?? '';
if (addressController.text != next) {
addressController.text = next;
}
return null;
}, [initialOverride]);
void emitChange() {
if (!enabled.value) {
ref.read(draftProvider.notifier).setDnsOverrideJson(null);
return;
}
final override = ProxyDnsOverride(
remoteServerAddress: addressController.text.trim().isEmpty
? null
: addressController.text.trim(),
);
ref
.read(draftProvider.notifier)
.setDnsOverrideJson(jsonEncode(override.toJson()));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Resolve names through a server reachable inside this profile '
'(e.g. an internal DoH server behind a corporate WireGuard). '
'Leave off to use automatic DNS handling.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: enabled.value,
onChanged: (value) {
enabled.value = value;
emitChange();
},
title: const Text('Use a profile-specific resolver'),
),
if (enabled.value) ...[
const SizedBox(height: 8),
TextField(
controller: addressController,
decoration: const InputDecoration(
labelText: 'DNS server address',
hintText: 'https://10.0.0.1/dns-query',
border: OutlineInputBorder(),
),
onChanged: (_) => emitChange(),
),
],
],
);
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class ProfileEditorSection extends StatelessWidget {
final String title;
final Widget child;
const ProfileEditorSection({
super.key,
required this.title,
required this.child,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Card.filled(
margin: EdgeInsets.zero,
color: scheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
child: child,
),
],
);
}
}
@@ -0,0 +1,297 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_spec.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
class StructuredProfileForm extends HookConsumerWidget {
final SingboxProxyFormSpec spec;
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const StructuredProfileForm({
super.key,
required this.spec,
required this.draftProvider,
required this.draft,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controllers = useMemoized(
() => {
for (final field in spec.fields) field.key: TextEditingController(),
},
[spec.type],
);
useEffect(() {
return () {
for (final controller in controllers.values) {
controller.dispose();
}
};
}, [controllers]);
useEffect(() {
_syncControllers(controllers, draft.values);
return null;
}, [controllers, draft.values]);
final sections = _structuredFieldSections(spec.fields);
final notifier = ref.read(draftProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (sectionIndex, section) in sections.indexed) ...[
if (sectionIndex > 0) const SizedBox(height: 24),
ProfileEditorSection(
title: section.title,
child: Padding(
padding: const EdgeInsets.all(16),
child: _SectionFields(
fields: section.fields,
controllers: controllers,
secretLoaded: draft.secretLoaded,
onChanged: notifier.setFieldValue,
),
),
),
],
const SizedBox(height: 12),
Text(
'Advanced protocol options can still be entered with Custom Outbound JSON.',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
class _SectionFields extends StatelessWidget {
final List<SingboxProxyFormField> fields;
final Map<String, TextEditingController> controllers;
final bool secretLoaded;
final void Function(String key, String value) onChanged;
const _SectionFields({
required this.fields,
required this.controllers,
required this.secretLoaded,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, field) in fields.indexed) ...[
if (index > 0) const SizedBox(height: 16),
if (field.isBoolean)
_BooleanField(
field: field,
controller: controllers[field.key]!,
onChanged: (value) => onChanged(field.key, value),
)
else if (field.isSecret)
ObscurableTextField(
controller: controllers[field.key],
enabled: secretLoaded,
keyboardType: field.isNumber
? TextInputType.number
: TextInputType.text,
textInputAction: index == fields.length - 1
? TextInputAction.done
: TextInputAction.next,
revealedMinLines: field.key == 'private_key' ? 4 : null,
revealedMaxLines: field.key == 'private_key' ? 8 : 1,
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
helperText: field.helperText ?? 'Stored in secure storage.',
border: const OutlineInputBorder(),
),
onChanged: (value) => onChanged(field.key, value),
)
else
TextField(
controller: controllers[field.key],
keyboardType: field.isNumber
? TextInputType.number
: field.isStringList
? TextInputType.multiline
: TextInputType.text,
textInputAction: field.isStringList
? TextInputAction.newline
: index == fields.length - 1
? TextInputAction.done
: TextInputAction.next,
minLines: field.isStringList ? 2 : 1,
maxLines: field.isStringList ? 4 : 1,
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
helperText: field.helperText,
border: const OutlineInputBorder(),
),
onChanged: (value) => onChanged(field.key, value),
),
],
],
);
}
}
({String title, List<SingboxProxyFormField> fields}) _section(
String title,
List<SingboxProxyFormField> fields,
) {
return (title: title, fields: fields);
}
List<({String title, List<SingboxProxyFormField> fields})>
_structuredFieldSections(List<SingboxProxyFormField> fields) {
final basic = <SingboxProxyFormField>[];
final tls = <SingboxProxyFormField>[];
final transport = <SingboxProxyFormField>[];
final multiplex = <SingboxProxyFormField>[];
final dial = <SingboxProxyFormField>[];
final secrets = <SingboxProxyFormField>[];
final protocol = <SingboxProxyFormField>[];
for (final field in fields) {
if (field.key.startsWith('tls.')) {
tls.add(field);
} else if (field.key.startsWith('transport.')) {
transport.add(field);
} else if (field.key.startsWith('multiplex.')) {
multiplex.add(field);
} else if (_dialFieldKeys.contains(field.key)) {
dial.add(field);
} else if (field.isSecret) {
secrets.add(field);
} else if (_basicFieldKeys.contains(field.key)) {
basic.add(field);
} else {
protocol.add(field);
}
}
return [
if (basic.isNotEmpty) _section('Connection', basic),
if (secrets.isNotEmpty) _section('Credentials', secrets),
if (protocol.isNotEmpty) _section('Protocol Options', protocol),
if (tls.isNotEmpty) _section('TLS', tls),
if (transport.isNotEmpty) _section('Transport', transport),
if (multiplex.isNotEmpty) _section('Multiplex', multiplex),
if (dial.isNotEmpty) _section('Dial', dial),
];
}
const _basicFieldKeys = {
'server',
'server_port',
'version',
'local_address',
'peer_public_key',
};
const _dialFieldKeys = {
'detour',
'bind_interface',
'routing_mark',
'domain_strategy',
'connect_timeout',
};
void _syncControllers(
Map<String, TextEditingController> controllers,
Map<String, String> values,
) {
for (final entry in controllers.entries) {
final next = values[entry.key] ?? '';
if (entry.value.text != next) {
entry.value.text = next;
}
}
}
class _BooleanField extends HookWidget {
final SingboxProxyFormField field;
final TextEditingController controller;
final ValueChanged<String> onChanged;
const _BooleanField({
required this.field,
required this.controller,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final value = useListenableSelector(
controller,
() => parseFormBool(controller.text),
);
return InputDecorator(
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
helperText: field.helperText,
helperMaxLines: 3,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
),
child: Row(
children: [
Expanded(
child: Text(
value == null
? 'Unset (uses default)'
: (value ? 'Enabled' : 'Disabled'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
if (value != null)
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
controller.text = '';
onChanged('');
},
),
Switch(
value: value ?? false,
onChanged: (next) {
final text = next ? 'true' : 'false';
controller.text = text;
onChanged(text);
},
),
],
),
);
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class IpChip extends StatelessWidget {
final String ip;
const IpChip({super.key, required this.ip});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Egress IP $ip',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
ip,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSecondaryContainer,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
class LatencyChip extends StatelessWidget {
final AsyncValue<ProxyLatencyData> result;
const LatencyChip({super.key, required this.result});
@override
Widget build(BuildContext context) {
return switch (result) {
AsyncLoading() => const _LatencyStatusChip.loading(),
AsyncError(:final error) => _LatencyStatusChip.error(error),
AsyncData(:final value) => _LatencySuccessChip(value: value),
};
}
}
class _LatencyStatusChip extends StatelessWidget {
final String label;
final String tooltip;
final bool isError;
const _LatencyStatusChip({
required this.label,
required this.tooltip,
required this.isError,
});
const _LatencyStatusChip.loading()
: this(
label: 'Testing...',
tooltip: 'Latency test running',
isError: false,
);
_LatencyStatusChip.error(Object error)
: this(label: 'Failed', tooltip: error.toString(), isError: true);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return _LatencyChipContainer(
label: label,
tooltip: tooltip,
backgroundColor: isError
? scheme.errorContainer
: scheme.surfaceContainerHighest,
foregroundColor: isError
? scheme.onErrorContainer
: scheme.onSurfaceVariant,
);
}
}
class _LatencySuccessChip extends StatelessWidget {
final ProxyLatencyData value;
const _LatencySuccessChip({required this.value});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (background, foreground) = _latencyColors(scheme, value.latency);
return _LatencyChipContainer(
label: '${value.latency.inMilliseconds} ms',
tooltip: 'HTTP ${value.statusCode} in ${value.latency.inMilliseconds} ms',
backgroundColor: background,
foregroundColor: foreground,
);
}
static (Color, Color) _latencyColors(ColorScheme scheme, Duration latency) {
final ms = latency.inMilliseconds;
if (ms < 500) return (scheme.primaryContainer, scheme.onPrimaryContainer);
if (ms < 1500) {
return (scheme.tertiaryContainer, scheme.onTertiaryContainer);
}
return (scheme.errorContainer, scheme.onErrorContainer);
}
}
class _LatencyChipContainer extends StatelessWidget {
final String label;
final String tooltip;
final Color backgroundColor;
final Color foregroundColor;
const _LatencyChipContainer({
required this.label,
required this.tooltip,
required this.backgroundColor,
required this.foregroundColor,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: foregroundColor),
),
),
);
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class MenuRow extends StatelessWidget {
final IconData icon;
final String label;
const MenuRow({super.key, required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(icon, size: 20), const SizedBox(width: 12), Text(label)],
);
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/ip_chip.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/latency_chip.dart';
class ProfileSubtitle extends StatelessWidget {
final String typeLabel;
final AsyncValue<ProxyLatencyData>? latency;
const ProfileSubtitle({
super.key,
required this.typeLabel,
required this.latency,
});
@override
Widget build(BuildContext context) {
final latency = this.latency;
if (latency == null) {
return Text(typeLabel);
}
final egressIp = latency.value?.egressIp;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(typeLabel),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
LatencyChip(result: latency),
if (egressIp != null) IpChip(ip: egressIp),
],
),
],
);
}
}
@@ -0,0 +1,249 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/proxy/data/models/proxy_share.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/menu_row.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_subtitle.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/protocol_badge.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/run_switch.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/share_profile_dialog.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/utils/ui_helper.dart';
enum ProfileAction { edit, testLatency, share, delete }
class ProfileTile extends ConsumerWidget {
final ProxyProfile profile;
final bool isRunning;
final bool isDeleting;
final bool runtimeBusy;
final void Function(String profileId, bool deleting) onDeletingChanged;
const ProfileTile({
super.key,
required this.profile,
required this.isRunning,
required this.isDeleting,
required this.runtimeBusy,
required this.onDeletingChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = runtimeBusy || isDeleting;
final latencyResult = ref.watch(
proxyLatencyResultsProvider.select(
(map) => map[SingboxProxyConnectionId(profile.id)],
),
);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: isDeleting
? const SizedBox.square(
dimension: 36,
child: Padding(
padding: EdgeInsets.all(6),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: ProtocolBadge(type: profile.type, active: isRunning),
title: Text(
profile.name,
style: TextStyle(
fontWeight: isRunning ? FontWeight.w600 : FontWeight.w500,
),
),
subtitle: ProfileSubtitle(
typeLabel: profile.type.label,
latency: latencyResult,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<ProfileAction>(
enabled: !isBusy,
onSelected: (action) => _onAction(context, ref, action),
itemBuilder: (context) => [
const PopupMenuItem(
value: ProfileAction.edit,
child: MenuRow(icon: Icons.edit_outlined, label: 'Edit'),
),
PopupMenuItem(
value: ProfileAction.testLatency,
enabled: isRunning,
child: MenuRow(
icon: latencyResult is AsyncLoading
? Icons.hourglass_bottom
: Icons.network_check,
label: 'Test connection',
),
),
const PopupMenuItem(
value: ProfileAction.share,
child: MenuRow(icon: Icons.share_outlined, label: 'Share'),
),
const PopupMenuItem(
value: ProfileAction.delete,
child: MenuRow(icon: Icons.delete_outline, label: 'Delete'),
),
],
),
RunSwitch(
isRunning: isRunning,
disabled: isBusy,
onTap: () => _toggleRunState(context, ref),
),
],
),
onTap: () =>
SingboxProxyProfileEditorRoute(profileId: profile.id).push(context),
);
}
Future<void> _toggleRunState(BuildContext context, WidgetRef ref) async {
try {
final notifier = ref.read(singboxProxyRuntimeRepositoryProvider.notifier);
if (isRunning) {
await notifier.stopProfiles([profile.id]);
ref
.read(proxyLatencyResultsProvider.notifier)
.clear(SingboxProxyConnectionId(profile.id));
} else {
await notifier.startProfile(profile.id);
}
} catch (error, stackTrace) {
logger.e(
'Failed to toggle singbox proxy run state for ${profile.id}',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
showErrorMessage(
context,
isRunning
? 'Failed to stop proxy: $error'
: 'Failed to start proxy: $error',
);
}
}
}
Future<void> _onAction(
BuildContext context,
WidgetRef ref,
ProfileAction action,
) async {
switch (action) {
case ProfileAction.edit:
await _handleEdit(context);
case ProfileAction.testLatency:
await _handleTestLatency(ref);
case ProfileAction.share:
await _handleShare(context, ref);
case ProfileAction.delete:
await _handleDelete(context, ref);
}
}
Future<void> _handleEdit(BuildContext context) {
return SingboxProxyProfileEditorRoute(profileId: profile.id).push(context);
}
Future<void> _handleTestLatency(WidgetRef ref) {
return ref.read(proxyLatencyResultsProvider.notifier).test(profile.id);
}
Future<void> _handleShare(BuildContext context, WidgetRef ref) async {
final secret = await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.readSecretJson(profile.id);
final shareUri = encodeProxyShareUri(
ProxyShareEnvelope(
name: profile.name,
type: profile.type,
configJson: profile.configJson,
secretJson: secret,
dnsOverrideJson: profile.dnsOverrideJson,
),
);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (context) =>
ShareProfileDialog(profileName: profile.name, shareUri: shareUri),
);
}
Future<void> _handleDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Profile?'),
content: Text(
isRunning
? 'Stop ${profile.name}, then delete it and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.'
: 'Delete ${profile.name} and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(isRunning ? 'Stop and Delete' : 'Delete'),
),
],
),
);
if (confirmed != true) return;
onDeletingChanged(profile.id, true);
try {
await ref
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.deleteProfile(profile.id);
ref
.read(proxyLatencyResultsProvider.notifier)
.clear(SingboxProxyConnectionId(profile.id));
} catch (error, stackTrace) {
logger.e(
'Failed to delete singbox proxy profile ${profile.id}',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
showErrorMessage(context, 'Failed to delete profile: $error');
}
} finally {
if (context.mounted) onDeletingChanged(profile.id, false);
}
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
class ProtocolBadge extends StatelessWidget {
final SingboxProxyProfileType type;
final bool active;
const ProtocolBadge({super.key, required this.type, required this.active});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = active
? scheme.primary.withValues(alpha: 0.15)
: scheme.surfaceContainerHighest;
final foreground = active ? scheme.primary : scheme.onSurfaceVariant;
return Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Text(
type.badge,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
);
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class RunSwitch extends StatelessWidget {
final bool isRunning;
final bool disabled;
final VoidCallback onTap;
const RunSwitch({
super.key,
required this.isRunning,
required this.disabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = isRunning
? scheme.primary
: scheme.surfaceContainerHighest;
final foreground = isRunning ? scheme.onPrimary : scheme.onSurface;
return IconButton.filled(
tooltip: isRunning ? 'Stop' : 'Start',
onPressed: disabled ? null : onTap,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
disabledBackgroundColor: scheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
),
icon: Icon(isRunning ? Icons.stop_rounded : Icons.play_arrow_rounded),
);
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import 'package:weblibre/utils/ui_helper.dart';
class ShareProfileDialog extends StatelessWidget {
final String profileName;
final String shareUri;
const ShareProfileDialog({
super.key,
required this.profileName,
required this.shareUri,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Share "$profileName"'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'This link contains the full profile, including any '
'stored credentials. Share carefully.',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(maxHeight: 160),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: SelectableText(
shareUri,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
),
],
),
actions: [
TextButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy'),
onPressed: () async {
await Clipboard.setData(ClipboardData(text: shareUri));
if (context.mounted) {
showInfoMessage(context, 'Copied to clipboard');
}
},
),
TextButton.icon(
icon: const Icon(Icons.share),
label: const Text('Share'),
onPressed: () async {
await SharePlus.instance.share(
ShareParams(text: shareUri, subject: profileName),
);
},
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
}
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class StatusHeader extends StatelessWidget {
final int totalCount;
final int runningCount;
final bool isBusy;
final VoidCallback? onStopAll;
const StatusHeader({
super.key,
required this.totalCount,
required this.runningCount,
required this.isBusy,
required this.onStopAll,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isAnyRunning = runningCount > 0;
final background = isAnyRunning
? scheme.primaryContainer
: scheme.surfaceContainerHigh;
final onBackground = isAnyRunning
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: onBackground.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(
isAnyRunning ? Icons.cloud_done : Icons.cloud_off_outlined,
color: onBackground,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isAnyRunning ? 'Active' : 'Disconnected',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: onBackground,
),
),
Text(
isAnyRunning
? '$runningCount of $totalCount routing traffic'
: 'Tap a profile to connect',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: onBackground.withValues(alpha: 0.8),
),
),
],
),
),
if (onStopAll != null)
IconButton.filled(
tooltip: 'Stop all',
onPressed: isBusy ? null : onStopAll,
style: IconButton.styleFrom(
backgroundColor: scheme.errorContainer,
foregroundColor: scheme.onErrorContainer,
),
icon: const Icon(Icons.stop_rounded),
),
],
),
);
}
}
@@ -0,0 +1,154 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/menu_row.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_subtitle.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/run_switch.dart';
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/presentation/icons/tor_icons.dart';
import 'package:weblibre/utils/ui_helper.dart';
enum TorAction { edit, testLatency }
class TorProfileTile extends ConsumerWidget {
final bool isRunning;
final bool isBusy;
const TorProfileTile({
super.key,
required this.isRunning,
required this.isBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final background = isRunning
? scheme.primary.withValues(alpha: 0.15)
: scheme.surfaceContainerHighest;
final foreground = isRunning ? scheme.primary : scheme.onSurfaceVariant;
final latencyResult = ref.watch(
proxyLatencyResultsProvider.select(
(map) => map[const TorProxyConnectionId()],
),
);
final torReady = ref.watch(
torProxyServiceProvider.select((s) => s.isReady),
);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Icon(TorIcons.onionAlt, color: foreground, size: 24),
),
title: Text(
'Tor',
style: TextStyle(
fontWeight: isRunning ? FontWeight.w600 : FontWeight.w500,
),
),
subtitle: ProfileSubtitle(
typeLabel: 'Onion routing',
latency: latencyResult,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<TorAction>(
enabled: !isBusy,
onSelected: (action) async {
switch (action) {
case TorAction.edit:
await const TorProxyRoute().push(context);
case TorAction.testLatency:
await ref
.read(proxyLatencyResultsProvider.notifier)
.testTor();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: TorAction.edit,
child: MenuRow(icon: Icons.edit_outlined, label: 'Edit'),
),
PopupMenuItem(
value: TorAction.testLatency,
enabled: torReady,
child: MenuRow(
icon: latencyResult is AsyncLoading
? Icons.hourglass_bottom
: Icons.network_check,
label: 'Test connection',
),
),
],
),
RunSwitch(
isRunning: isRunning,
disabled: isBusy,
onTap: () => _toggle(context, ref),
),
],
),
onTap: () => const TorProxyRoute().push(context),
);
}
Future<void> _toggle(BuildContext context, WidgetRef ref) async {
try {
final service = ref.read(torProxyServiceProvider.notifier);
if (isRunning) {
await service.disconnect();
ref
.read(proxyLatencyResultsProvider.notifier)
.clear(const TorProxyConnectionId());
} else {
await service.startOrReconfigure(reconfigureIfRunning: false);
}
} catch (error, stackTrace) {
logger.e(
'Failed to toggle Tor proxy',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
showErrorMessage(
context,
isRunning
? 'Failed to stop Tor: $error'
: 'Failed to start Tor: $error',
);
}
}
}
}