Add proxy routing and sing-box support
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> proxyRoutingSettingsSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Regular Tabs',
|
||||
keywords: ['routing'],
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Regular Tabs Routing Mode',
|
||||
subtitle: 'Choose how regular tabs are routed through proxies',
|
||||
keywords: ['container', 'global'],
|
||||
child: _RegularTabsModeSection(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Proxy for global routing',
|
||||
subtitle: 'Selected proxy when global routing is enabled',
|
||||
keywords: ['proxy'],
|
||||
child: _GlobalRoutingProxySection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Private Tabs',
|
||||
keywords: ['private', 'incognito'],
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Proxy for private tabs',
|
||||
subtitle: 'Selected proxy that carries private-tab traffic',
|
||||
keywords: ['proxy'],
|
||||
child: _PrivateTabsProxySection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
class ProxyRoutingSettingsScreen extends StatelessWidget {
|
||||
const ProxyRoutingSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Proxy Routing',
|
||||
subtitle: 'Choose which proxy carries regular and private tab traffic.',
|
||||
icon: Icons.route_outlined,
|
||||
sections: proxyRoutingSettingsSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RegularTabsModeSection extends ConsumerWidget {
|
||||
const _RegularTabsModeSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||
|
||||
return RadioGroup<ProxyRegularTabRoutingMode>(
|
||||
groupValue: settings.regularTabsMode,
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
await ref
|
||||
.read(proxyRoutingSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith(regularTabsMode: value),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
|
||||
value: ProxyRegularTabRoutingMode.container,
|
||||
title: Text('Container-Based Routing'),
|
||||
subtitle: Text(
|
||||
'Only tabs in containers with a proxy assigned are routed.',
|
||||
),
|
||||
),
|
||||
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
|
||||
value: ProxyRegularTabRoutingMode.all,
|
||||
title: Text('Global Routing'),
|
||||
subtitle: Text(
|
||||
'Route every regular tab through the selected proxy.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlobalRoutingProxySection extends ConsumerWidget {
|
||||
const _GlobalRoutingProxySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||
if (settings.regularTabsMode != ProxyRegularTabRoutingMode.all) {
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Not used in container-based routing'),
|
||||
subtitle: Text(
|
||||
'Switch to global routing above to pick the proxy that carries every regular tab.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final options = ref.watch(proxyConnectionOptionsProvider);
|
||||
return _ProxyConnectionPicker(
|
||||
options: options,
|
||||
selectedId: settings.regularTabsProxyConnectionId,
|
||||
onChanged: (id) => ref
|
||||
.read(proxyRoutingSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith(regularTabsProxyConnectionId: id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivateTabsProxySection extends ConsumerWidget {
|
||||
const _PrivateTabsProxySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||
final options = ref.watch(proxyConnectionOptionsProvider);
|
||||
return _ProxyConnectionPicker(
|
||||
options: options,
|
||||
selectedId: settings.privateTabsProxyConnectionId,
|
||||
onChanged: (id) => ref
|
||||
.read(proxyRoutingSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith(privateTabsProxyConnectionId: id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProxyConnectionPicker extends StatelessWidget {
|
||||
final List<ProxyConnectionOption> options;
|
||||
final ProxyConnectionId? selectedId;
|
||||
final ValueChanged<ProxyConnectionId?> onChanged;
|
||||
|
||||
const _ProxyConnectionPicker({
|
||||
required this.options,
|
||||
required this.selectedId,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasUnknownSelection =
|
||||
selectedId != null && !options.any((option) => option.id == selectedId);
|
||||
|
||||
return RadioGroup<ProxyConnectionId?>(
|
||||
groupValue: selectedId,
|
||||
onChanged: onChanged,
|
||||
child: Column(
|
||||
children: [
|
||||
const RadioListTile<ProxyConnectionId?>.adaptive(
|
||||
value: null,
|
||||
title: Text('None'),
|
||||
subtitle: Text('Use the normal browser connection'),
|
||||
secondary: Icon(Icons.public),
|
||||
),
|
||||
if (hasUnknownSelection)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
title: const Text('Unknown proxy'),
|
||||
subtitle: const Text('The selected proxy no longer exists.'),
|
||||
trailing: TextButton(
|
||||
onPressed: () => onChanged(null),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
),
|
||||
for (final option in options)
|
||||
RadioListTile<ProxyConnectionId?>.adaptive(
|
||||
value: option.id,
|
||||
title: Text(option.title),
|
||||
subtitle: Text(option.subtitle),
|
||||
secondary: const Icon(Icons.route_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_log_message.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class SingboxProxyLogsScreen extends HookConsumerWidget {
|
||||
const SingboxProxyLogsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final logs = ref.watch(singboxProxyLogsProvider);
|
||||
final filter = useState<String?>(null);
|
||||
final autoScroll = useState(true);
|
||||
final scrollController = useScrollController();
|
||||
|
||||
// Stick to the bottom when new lines arrive — unless the user scrolled up.
|
||||
// We coalesce scroll-to-bottom across rapid bursts via a pending flag so a
|
||||
// chatty proxy can't fight the user trying to scroll up.
|
||||
final pendingAutoScroll = useRef(false);
|
||||
useEffect(() {
|
||||
if (!autoScroll.value) return null;
|
||||
if (pendingAutoScroll.value) return null;
|
||||
pendingAutoScroll.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
pendingAutoScroll.value = false;
|
||||
if (!autoScroll.value) return;
|
||||
if (scrollController.hasClients) {
|
||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}, [logs.length, autoScroll.value]);
|
||||
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
if (!scrollController.hasClients) return;
|
||||
final atBottom =
|
||||
scrollController.position.pixels >=
|
||||
scrollController.position.maxScrollExtent - 24;
|
||||
if (autoScroll.value != atBottom) {
|
||||
autoScroll.value = atBottom;
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [scrollController]);
|
||||
|
||||
final filtered = filter.value == null
|
||||
? logs
|
||||
: logs.where((m) => m.level.toLowerCase() == filter.value).toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Proxy Logs'),
|
||||
actions: [
|
||||
PopupMenuButton<String?>(
|
||||
tooltip: 'Filter by level',
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onSelected: (value) => filter.value = value,
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem<String?>(child: Text('All levels')),
|
||||
PopupMenuItem(value: 'error', child: Text('Error')),
|
||||
PopupMenuItem(value: 'warn', child: Text('Warning')),
|
||||
PopupMenuItem(value: 'info', child: Text('Info')),
|
||||
PopupMenuItem(value: 'debug', child: Text('Debug')),
|
||||
PopupMenuItem(value: 'trace', child: Text('Trace')),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Copy all',
|
||||
icon: const Icon(Icons.copy_all),
|
||||
onPressed: filtered.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: _formatLogs(filtered)),
|
||||
);
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Copied to clipboard');
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Share',
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: filtered.isEmpty
|
||||
? null
|
||||
: () => SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: _formatLogs(filtered),
|
||||
subject: 'proxy logs',
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: logs.isEmpty
|
||||
? null
|
||||
: () => ref.read(singboxProxyLogsProvider.notifier).clear(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: filtered.isEmpty
|
||||
? _EmptyLogs(hasFilter: filter.value != null)
|
||||
: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_LogLine(message: filtered[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogLine extends StatelessWidget {
|
||||
final ProxyLogMessage message;
|
||||
|
||||
const _LogLine({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = switch (message.level.toLowerCase()) {
|
||||
'error' || 'fatal' => scheme.error,
|
||||
'warn' || 'warning' => scheme.tertiary,
|
||||
_ => scheme.onSurface,
|
||||
};
|
||||
final time = DateFormat(
|
||||
'HH:mm:ss',
|
||||
).format(DateTime.fromMillisecondsSinceEpoch(message.timestamp));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: SelectableText.rich(
|
||||
TextSpan(
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: color,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$time ',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
TextSpan(
|
||||
text: '[${_sourceLabel(message.source)}] ',
|
||||
style: TextStyle(color: scheme.primary),
|
||||
),
|
||||
TextSpan(
|
||||
text: '[${message.level}] ',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (message.profileId != null)
|
||||
TextSpan(
|
||||
text: '${message.profileId} ',
|
||||
style: TextStyle(color: scheme.primary),
|
||||
),
|
||||
TextSpan(text: message.message),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyLogs extends StatelessWidget {
|
||||
final bool hasFilter;
|
||||
|
||||
const _EmptyLogs({required this.hasFilter});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
hasFilter
|
||||
? 'No log lines match the current filter.'
|
||||
: 'No log lines yet. Start a proxy or Tor to see output here.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLogs(List<ProxyLogMessage> messages) {
|
||||
final buffer = StringBuffer();
|
||||
for (final m in messages) {
|
||||
final time = DateTime.fromMillisecondsSinceEpoch(
|
||||
m.timestamp,
|
||||
).toIso8601String();
|
||||
buffer.writeln(
|
||||
'$time [${_sourceLabel(m.source)}] [${m.level}]${m.profileId == null ? '' : ' (${m.profileId})'} ${m.message}',
|
||||
);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _sourceLabel(ProxyLogSource source) {
|
||||
return switch (source) {
|
||||
ProxyLogSource.singBox => 'sing-box',
|
||||
ProxyLogSource.tor => 'tor',
|
||||
};
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/custom_outbound_profile_form.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_dns_override_section.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/structured_profile_form.dart';
|
||||
import 'package:weblibre/presentation/widgets/button_spinner.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class SingboxProxyProfileEditorScreen extends ConsumerWidget {
|
||||
final String? profileId;
|
||||
final ProxyProfileSeed? seed;
|
||||
|
||||
const SingboxProxyProfileEditorScreen({super.key, this.profileId, this.seed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final draftProvider = proxyProfileDraftProvider(
|
||||
profileId: profileId,
|
||||
seed: seed,
|
||||
);
|
||||
final draft = ref.watch(draftProvider);
|
||||
|
||||
if (draft.isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Edit Profile')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (draft.loadError != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Edit Profile')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(draft.loadError!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _Editor(draftProvider: draftProvider, draft: draft);
|
||||
}
|
||||
}
|
||||
|
||||
class _Editor extends ConsumerWidget {
|
||||
final ProxyProfileDraftProvider draftProvider;
|
||||
final ProxyProfileDraftState draft;
|
||||
|
||||
const _Editor({required this.draftProvider, required this.draft});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Future<void> handleSave() async {
|
||||
final outcome = await ref.read(draftProvider.notifier).save();
|
||||
if (!context.mounted) return;
|
||||
|
||||
switch (outcome) {
|
||||
case SaveSucceeded():
|
||||
Navigator.pop(context);
|
||||
case SaveFailed(:final message):
|
||||
showErrorMessage(context, message);
|
||||
}
|
||||
}
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: draft.isSaving ? null : handleSave,
|
||||
icon: draft.isSaving
|
||||
? const ButtonSpinner()
|
||||
: const Icon(Icons.check),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
label: Text(draft.isEditing ? 'Save Changes' : 'Create Profile'),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SliverAppBar.large(
|
||||
centerTitle: false,
|
||||
title: Text(draft.isEditing ? 'Edit Profile' : 'New Profile'),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate.fixed([
|
||||
ProfileEditorSection(
|
||||
title: 'General',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _GeneralSection(
|
||||
draftProvider: draftProvider,
|
||||
draft: draft,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ProtocolForm(draftProvider: draftProvider, draft: draft),
|
||||
const SizedBox(height: 24),
|
||||
ProfileEditorSection(
|
||||
title: 'DNS Override',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: ProfileDnsOverrideSection(
|
||||
draftProvider: draftProvider,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!draft.isEditing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
'Tip: use the add menu on the previous screen to '
|
||||
'import from a file, paste a share link, or scan '
|
||||
'a QR code.',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GeneralSection extends HookConsumerWidget {
|
||||
final ProxyProfileDraftProvider draftProvider;
|
||||
final ProxyProfileDraftState draft;
|
||||
|
||||
const _GeneralSection({required this.draftProvider, required this.draft});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final nameController = useTextEditingController(text: draft.name);
|
||||
useEffect(() {
|
||||
if (nameController.text != draft.name) {
|
||||
nameController.text = draft.name;
|
||||
}
|
||||
return null;
|
||||
}, [draft.name]);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Profile Name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: ref.read(draftProvider.notifier).setName,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (draft.isEditing)
|
||||
// Protocol is locked after creation: each type stores a different
|
||||
// config/secret JSON shape, so switching mid-edit would silently
|
||||
// rewrite the profile under a foreign schema. To change protocol,
|
||||
// create a new profile.
|
||||
InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Protocol',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'Protocol is fixed once a profile is created.',
|
||||
),
|
||||
child: Text(draft.type.label),
|
||||
)
|
||||
else
|
||||
DropdownButtonFormField<SingboxProxyProfileType>(
|
||||
key: ValueKey(draft.type),
|
||||
initialValue: draft.type,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Protocol',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final type in SingboxProxyProfileType.values)
|
||||
DropdownMenuItem(value: type, child: Text(type.label)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
ref.read(draftProvider.notifier).setType(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProtocolForm extends StatelessWidget {
|
||||
final ProxyProfileDraftProvider draftProvider;
|
||||
final ProxyProfileDraftState draft;
|
||||
|
||||
const _ProtocolForm({required this.draftProvider, required this.draft});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spec = singboxProxyFormSpecs[draft.type];
|
||||
if (spec != null) {
|
||||
return StructuredProfileForm(
|
||||
key: ValueKey((draft.type, draft.profileId)),
|
||||
spec: spec,
|
||||
draftProvider: draftProvider,
|
||||
draft: draft,
|
||||
);
|
||||
}
|
||||
|
||||
return CustomOutboundProfileForm(
|
||||
key: ValueKey(('custom', draft.profileId)),
|
||||
draftProvider: draftProvider,
|
||||
draft: draft,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* 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:async';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.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/repositories/singbox_proxy_profiles.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/add_proxy_method_sheet.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_tile.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/status_header.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/tor_tile.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/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class SingboxProxyProfilesScreen extends HookConsumerWidget {
|
||||
const SingboxProxyProfilesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profilesAsync = ref.watch(singboxProxyProfilesRepositoryProvider);
|
||||
final runtimeState = ref.watch(singboxProxyRuntimeRepositoryProvider);
|
||||
final torState = ref.watch(torProxyServiceProvider);
|
||||
final deletingProfileIds = useState(<String>{});
|
||||
|
||||
final activeProfileIds = _activeProfileIds(runtimeState);
|
||||
final runtimeBusy = runtimeState.isLoading;
|
||||
final torIsRunning = torState.value?.isRunning ?? false;
|
||||
final torIsBusy = torState.isBusy;
|
||||
|
||||
// Drop cached latency results for profiles that are no longer running so a
|
||||
// stale "120 ms" chip can't outlive its connection.
|
||||
ref.listen(singboxProxyRuntimeRepositoryProvider, (_, _) {
|
||||
_pruneLatencyCache(ref);
|
||||
});
|
||||
ref.listen(torProxyServiceProvider, (_, _) {
|
||||
_pruneLatencyCache(ref);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => unawaited(_showAddSheet(context)),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Profile'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SliverAppBar.large(
|
||||
centerTitle: false,
|
||||
title: const Text('Proxy Connections'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'View logs',
|
||||
icon: const Icon(Icons.subject),
|
||||
onPressed: () =>
|
||||
const SingboxProxyLogsRoute().push(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
...profilesAsync.when(
|
||||
data: (profiles) {
|
||||
// Prune ids whose profile was deleted (or otherwise
|
||||
// disappeared) so the set can't grow unbounded if a tile
|
||||
// is unmounted while its delete is still in flight.
|
||||
final liveProfileIds = {
|
||||
for (final profile in profiles) profile.id,
|
||||
};
|
||||
final pruned = deletingProfileIds.value.intersection(
|
||||
liveProfileIds,
|
||||
);
|
||||
if (pruned.length != deletingProfileIds.value.length) {
|
||||
// Schedule for the next frame to avoid mutating state
|
||||
// during build.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
deletingProfileIds.value = pruned;
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
_ProfileListBody(
|
||||
profiles: profiles,
|
||||
activeProfileIds: activeProfileIds,
|
||||
deletingProfileIds: pruned,
|
||||
runtimeBusy: runtimeBusy,
|
||||
torIsRunning: torIsRunning,
|
||||
torIsBusy: torIsBusy,
|
||||
onDeletingChanged: (id, deleting) {
|
||||
final next = {...deletingProfileIds.value};
|
||||
if (deleting) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.remove(id);
|
||||
}
|
||||
deletingProfileIds.value = next;
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
loading: () => const [
|
||||
SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
error: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Failed to load singbox proxy profiles',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Failed to load proxy profiles:\n$error',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Set<ProxyConnectionId> _activeConnectionIds(
|
||||
AsyncValue<SingboxProxyRuntimeState> runtimeState,
|
||||
) {
|
||||
return runtimeState.asData?.value.endpoints
|
||||
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
|
||||
.nonNulls
|
||||
.toSet() ??
|
||||
const <ProxyConnectionId>{};
|
||||
}
|
||||
|
||||
Set<String> _activeProfileIds(
|
||||
AsyncValue<SingboxProxyRuntimeState> runtimeState,
|
||||
) {
|
||||
return _activeConnectionIds(
|
||||
runtimeState,
|
||||
).whereType<SingboxProxyConnectionId>().map((id) => id.profileId).toSet();
|
||||
}
|
||||
|
||||
void _pruneLatencyCache(WidgetRef ref) {
|
||||
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider);
|
||||
final torRunning =
|
||||
ref.read(torProxyServiceProvider).value?.isRunning ?? false;
|
||||
ref.read(proxyLatencyResultsProvider.notifier).retainRunning({
|
||||
..._activeConnectionIds(runtimeState),
|
||||
if (torRunning) const TorProxyConnectionId(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showAddSheet(BuildContext context) async {
|
||||
final action = await showModalBottomSheet<AddProxyAction>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const AddProxyMethodSheet(),
|
||||
);
|
||||
if (action == null) return;
|
||||
if (!context.mounted) return;
|
||||
switch (action) {
|
||||
case AddProxyManual():
|
||||
await const SingboxProxyProfileEditorRoute().push(context);
|
||||
case AddProxySubscription():
|
||||
await const SubscriptionImportRoute().push(context);
|
||||
case AddProxyWithSeed(:final seed):
|
||||
await SingboxProxyProfileEditorRoute($extra: seed).push(context);
|
||||
case AddProxyImported(:final message):
|
||||
showInfoMessage(context, message);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileListBody extends ConsumerWidget {
|
||||
final List<ProxyProfile> profiles;
|
||||
final Set<String> activeProfileIds;
|
||||
final Set<String> deletingProfileIds;
|
||||
final bool runtimeBusy;
|
||||
final bool torIsRunning;
|
||||
final bool torIsBusy;
|
||||
final void Function(String id, bool deleting) onDeletingChanged;
|
||||
|
||||
const _ProfileListBody({
|
||||
required this.profiles,
|
||||
required this.activeProfileIds,
|
||||
required this.deletingProfileIds,
|
||||
required this.runtimeBusy,
|
||||
required this.torIsRunning,
|
||||
required this.torIsBusy,
|
||||
required this.onDeletingChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sorted = [...profiles]
|
||||
..sort((a, b) {
|
||||
final aRunning = activeProfileIds.contains(a.id);
|
||||
final bRunning = activeProfileIds.contains(b.id);
|
||||
if (aRunning == bRunning) return 0;
|
||||
return aRunning ? -1 : 1;
|
||||
});
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final totalRunning = activeProfileIds.length + (torIsRunning ? 1 : 0);
|
||||
final totalCount = profiles.length + 1;
|
||||
|
||||
Future<void> stopAll() async {
|
||||
await ref.read(singboxProxyRuntimeRepositoryProvider.notifier).stopAll();
|
||||
ref.read(proxyLatencyResultsProvider.notifier).retainRunning(const {});
|
||||
if (torIsRunning) {
|
||||
await ref.read(torProxyServiceProvider.notifier).disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return SliverList.list(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: StatusHeader(
|
||||
totalCount: totalCount,
|
||||
runningCount: totalRunning,
|
||||
isBusy: runtimeBusy || torIsBusy,
|
||||
onStopAll: totalRunning == 0 ? null : stopAll,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
'Profiles',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
|
||||
child: Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: scheme.surfaceContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
TorProfileTile(isRunning: torIsRunning, isBusy: torIsBusy),
|
||||
for (final profile in sorted) ...[
|
||||
const Divider(height: 1),
|
||||
ProfileTile(
|
||||
profile: profile,
|
||||
isRunning: activeProfileIds.contains(profile.id),
|
||||
isDeleting: deletingProfileIds.contains(profile.id),
|
||||
runtimeBusy: runtimeBusy,
|
||||
onDeletingChanged: onDeletingChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* 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/core/logger.dart';
|
||||
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||
import 'package:weblibre/features/proxy/domain/services/subscription_importer.dart';
|
||||
import 'package:weblibre/presentation/widgets/button_spinner.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class SubscriptionImportScreen extends HookConsumerWidget {
|
||||
const SubscriptionImportScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final urlController = useTextEditingController();
|
||||
final hasUrl = useListenableSelector(
|
||||
urlController,
|
||||
() => urlController.text.trim().isNotEmpty,
|
||||
);
|
||||
final fetching = useState(false);
|
||||
final result = useState<SubscriptionImportResult?>(null);
|
||||
final selection = useState(<int>{});
|
||||
final fetchError = useState<String?>(null);
|
||||
final isImporting = useState(false);
|
||||
|
||||
Future<void> fetch() async {
|
||||
final raw = urlController.text.trim();
|
||||
if (raw.isEmpty) return;
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri == null || !uri.hasScheme) {
|
||||
fetchError.value = 'Enter a full https:// subscription URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
fetching.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
final outcome = await fetchSubscription(uri);
|
||||
result.value = outcome;
|
||||
selection.value = {
|
||||
for (final (index, entry) in outcome.entries.indexed)
|
||||
if (entry is SubscriptionEntrySuccess) index,
|
||||
};
|
||||
} catch (error, stackTrace) {
|
||||
logger.e(
|
||||
'Failed to fetch subscription from $uri',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
fetchError.value = error.toString();
|
||||
result.value = null;
|
||||
} finally {
|
||||
if (context.mounted) fetching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> importSelected() async {
|
||||
final outcome = result.value;
|
||||
if (outcome == null) return;
|
||||
isImporting.value = true;
|
||||
var imported = 0;
|
||||
try {
|
||||
final notifier = ref.read(
|
||||
singboxProxyProfilesRepositoryProvider.notifier,
|
||||
);
|
||||
for (final (index, entry) in outcome.entries.indexed) {
|
||||
if (!selection.value.contains(index)) continue;
|
||||
if (entry is! SubscriptionEntrySuccess) continue;
|
||||
|
||||
final parsed = entry.imported;
|
||||
final spec = singboxProxyFormSpecs[parsed.type];
|
||||
if (spec == null) continue;
|
||||
await notifier.createProfile(
|
||||
name: parsed.name ?? 'Imported ${imported + 1}',
|
||||
type: parsed.type,
|
||||
configJson: spec.toConfigJson(parsed.values),
|
||||
secretJson: spec.toSecretJson(parsed.values),
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) isImporting.value = false;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Imported $imported profile(s)');
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Import Subscription')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: urlController,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Subscription URL',
|
||||
hintText: 'https://example.com/sub',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Supports the v2rayN-style format: a base64-encoded list of '
|
||||
'ss://, vless://, vmess://, trojan://, hysteria2://, tuic:// '
|
||||
'and similar URIs. Routing rules from the subscription are '
|
||||
'ignored — only proxy nodes are imported.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: !hasUrl || fetching.value ? null : fetch,
|
||||
icon: fetching.value
|
||||
? const ButtonSpinner()
|
||||
: const Icon(Icons.cloud_download_outlined),
|
||||
label: const Text('Fetch'),
|
||||
),
|
||||
if (fetchError.value != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
fetchError.value!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
if (result.value != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_ResultsSection(
|
||||
result: result.value!,
|
||||
selectedIndices: selection.value,
|
||||
isImporting: isImporting.value,
|
||||
onSelectionChanged: (next) => selection.value = next,
|
||||
onImport: importSelected,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultsSection extends StatelessWidget {
|
||||
final SubscriptionImportResult result;
|
||||
final Set<int> selectedIndices;
|
||||
final bool isImporting;
|
||||
final ValueChanged<Set<int>> onSelectionChanged;
|
||||
final Future<void> Function() onImport;
|
||||
|
||||
const _ResultsSection({
|
||||
required this.result,
|
||||
required this.selectedIndices,
|
||||
required this.isImporting,
|
||||
required this.onSelectionChanged,
|
||||
required this.onImport,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final successCount = result.successes.length;
|
||||
final failureCount = result.failures.length;
|
||||
|
||||
void selectAll() {
|
||||
onSelectionChanged({
|
||||
for (final (index, entry) in result.entries.indexed)
|
||||
if (entry is SubscriptionEntrySuccess) index,
|
||||
});
|
||||
}
|
||||
|
||||
void toggle(int index, bool selected) {
|
||||
final next = {...selectedIndices};
|
||||
if (selected) {
|
||||
next.add(index);
|
||||
} else {
|
||||
next.remove(index);
|
||||
}
|
||||
onSelectionChanged(next);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$successCount usable node(s)'
|
||||
'${failureCount > 0 ? ', $failureCount failed' : ''}',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: successCount == 0 ? null : selectAll,
|
||||
child: const Text('Select all'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => onSelectionChanged(const {}),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
for (final (index, entry) in result.entries.indexed)
|
||||
_EntryTile(
|
||||
entry: entry,
|
||||
selected: selectedIndices.contains(index),
|
||||
onChanged: entry is SubscriptionEntrySuccess
|
||||
? (checked) => toggle(index, checked ?? false)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: isImporting || selectedIndices.isEmpty ? null : onImport,
|
||||
icon: isImporting
|
||||
? const ButtonSpinner()
|
||||
: const Icon(Icons.download_done),
|
||||
label: Text('Import ${selectedIndices.length} profile(s)'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EntryTile extends StatelessWidget {
|
||||
final SubscriptionImportEntry entry;
|
||||
final bool selected;
|
||||
final ValueChanged<bool?>? onChanged;
|
||||
|
||||
const _EntryTile({
|
||||
required this.entry,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (entry) {
|
||||
SubscriptionEntrySuccess(:final imported) => CheckboxListTile(
|
||||
value: selected,
|
||||
onChanged: onChanged,
|
||||
title: Text(imported.name ?? entry.rawLine),
|
||||
subtitle: Text(
|
||||
imported.type.name,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
dense: true,
|
||||
),
|
||||
SubscriptionEntryFailure(:final error) => ListTile(
|
||||
leading: Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
entry.rawLine,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
error is FormatException ? error.message : error.toString(),
|
||||
),
|
||||
dense: true,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user