Refactor
This commit is contained in:
12
lib/features/proxy/data/proxy_data_providers.dart
Normal file
12
lib/features/proxy/data/proxy_data_providers.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:hiddify/features/proxy/data/proxy_repository.dart';
|
||||
import 'package:hiddify/singbox/service/singbox_service_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'proxy_data_providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProxyRepository proxyRepository(ProxyRepositoryRef ref) {
|
||||
return ProxyRepositoryImpl(
|
||||
singbox: ref.watch(singboxServiceProvider),
|
||||
);
|
||||
}
|
||||
78
lib/features/proxy/data/proxy_repository.dart
Normal file
78
lib/features/proxy/data/proxy_repository.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/utils/exception_handler.dart';
|
||||
import 'package:hiddify/features/proxy/model/proxy_entity.dart';
|
||||
import 'package:hiddify/features/proxy/model/proxy_failure.dart';
|
||||
import 'package:hiddify/singbox/service/singbox_service.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
|
||||
abstract interface class ProxyRepository {
|
||||
Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> watchProxies();
|
||||
TaskEither<ProxyFailure, Unit> selectProxy(
|
||||
String groupTag,
|
||||
String outboundTag,
|
||||
);
|
||||
TaskEither<ProxyFailure, Unit> urlTest(String groupTag);
|
||||
}
|
||||
|
||||
class ProxyRepositoryImpl
|
||||
with ExceptionHandler, InfraLogger
|
||||
implements ProxyRepository {
|
||||
ProxyRepositoryImpl({required this.singbox});
|
||||
|
||||
final SingboxService singbox;
|
||||
|
||||
@override
|
||||
Stream<Either<ProxyFailure, List<ProxyGroupEntity>>> watchProxies() {
|
||||
return singbox.watchOutbounds().map((event) {
|
||||
final groupWithSelected = {
|
||||
for (final group in event) group.tag: group.selected,
|
||||
};
|
||||
return event
|
||||
.map(
|
||||
(e) => ProxyGroupEntity(
|
||||
tag: e.tag,
|
||||
type: e.type,
|
||||
selected: e.selected,
|
||||
items: e.items
|
||||
.map(
|
||||
(e) => ProxyItemEntity(
|
||||
tag: e.tag,
|
||||
type: e.type,
|
||||
urlTestDelay: e.urlTestDelay,
|
||||
selectedTag: groupWithSelected[e.tag],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}).handleExceptions(
|
||||
(error, stackTrace) {
|
||||
loggy.error("error watching proxies", error, stackTrace);
|
||||
return ProxyUnexpectedFailure(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProxyFailure, Unit> selectProxy(
|
||||
String groupTag,
|
||||
String outboundTag,
|
||||
) {
|
||||
return exceptionHandler(
|
||||
() => singbox
|
||||
.selectOutbound(groupTag, outboundTag)
|
||||
.mapLeft(ProxyUnexpectedFailure.new)
|
||||
.run(),
|
||||
ProxyUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProxyFailure, Unit> urlTest(String groupTag) {
|
||||
return exceptionHandler(
|
||||
() => singbox.urlTest(groupTag).mapLeft(ProxyUnexpectedFailure.new).run(),
|
||||
ProxyUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/features/proxy/model/proxy_entity.dart
Normal file
37
lib/features/proxy/model/proxy_entity.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/singbox/model/singbox_proxy_type.dart';
|
||||
|
||||
part 'proxy_entity.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ProxyGroupEntity with _$ProxyGroupEntity {
|
||||
const ProxyGroupEntity._();
|
||||
|
||||
const factory ProxyGroupEntity({
|
||||
required String tag,
|
||||
required ProxyType type,
|
||||
required String selected,
|
||||
@Default([]) List<ProxyItemEntity> items,
|
||||
}) = _ProxyGroupEntity;
|
||||
|
||||
String get name => _sanitizedTag(tag);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProxyItemEntity with _$ProxyItemEntity {
|
||||
const ProxyItemEntity._();
|
||||
|
||||
const factory ProxyItemEntity({
|
||||
required String tag,
|
||||
required ProxyType type,
|
||||
required int urlTestDelay,
|
||||
String? selectedTag,
|
||||
}) = _ProxyItemEntity;
|
||||
|
||||
String get name => _sanitizedTag(tag);
|
||||
String? get selectedName =>
|
||||
selectedTag == null ? null : _sanitizedTag(selectedTag!);
|
||||
}
|
||||
|
||||
String _sanitizedTag(String tag) =>
|
||||
tag.replaceFirst(RegExp(r"\§[^]*"), "").trimRight();
|
||||
33
lib/features/proxy/model/proxy_failure.dart
Normal file
33
lib/features/proxy/model/proxy_failure.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/localization/translations.dart';
|
||||
import 'package:hiddify/core/model/failures.dart';
|
||||
|
||||
part 'proxy_failure.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ProxyFailure with _$ProxyFailure, Failure {
|
||||
const ProxyFailure._();
|
||||
|
||||
@With<UnexpectedFailure>()
|
||||
const factory ProxyFailure.unexpected([
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
]) = ProxyUnexpectedFailure;
|
||||
|
||||
@With<ExpectedFailure>()
|
||||
const factory ProxyFailure.serviceNotRunning() = ServiceNotRunning;
|
||||
|
||||
@override
|
||||
({String type, String? message}) present(TranslationsEn t) {
|
||||
return switch (this) {
|
||||
ProxyUnexpectedFailure() => (
|
||||
type: t.failure.unexpected,
|
||||
message: null,
|
||||
),
|
||||
ServiceNotRunning() => (
|
||||
type: t.failure.singbox.serviceNotRunning,
|
||||
message: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
153
lib/features/proxy/overview/proxies_overview_notifier.dart
Normal file
153
lib/features/proxy/overview/proxies_overview_notifier.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:combine/combine.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/core/localization/translations.dart';
|
||||
import 'package:hiddify/core/preferences/preferences_provider.dart';
|
||||
import 'package:hiddify/features/connection/notifier/connection_notifier.dart';
|
||||
import 'package:hiddify/features/proxy/data/proxy_data_providers.dart';
|
||||
import 'package:hiddify/features/proxy/model/proxy_entity.dart';
|
||||
import 'package:hiddify/features/proxy/model/proxy_failure.dart';
|
||||
import 'package:hiddify/utils/pref_notifier.dart';
|
||||
import 'package:hiddify/utils/riverpod_utils.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'proxies_overview_notifier.g.dart';
|
||||
|
||||
enum ProxiesSort {
|
||||
unsorted,
|
||||
name,
|
||||
delay;
|
||||
|
||||
String present(TranslationsEn t) => switch (this) {
|
||||
ProxiesSort.unsorted => t.proxies.sortOptions.unsorted,
|
||||
ProxiesSort.name => t.proxies.sortOptions.name,
|
||||
ProxiesSort.delay => t.proxies.sortOptions.delay,
|
||||
};
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProxiesSortNotifier extends _$ProxiesSortNotifier {
|
||||
late final _pref = Pref(
|
||||
ref.watch(sharedPreferencesProvider).requireValue,
|
||||
"proxies_sort_mode",
|
||||
ProxiesSort.unsorted,
|
||||
mapFrom: ProxiesSort.values.byName,
|
||||
mapTo: (value) => value.name,
|
||||
);
|
||||
|
||||
@override
|
||||
ProxiesSort build() => _pref.getValue();
|
||||
|
||||
Future<void> update(ProxiesSort value) {
|
||||
state = value;
|
||||
return _pref.update(value);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ProxiesOverviewNotifier extends _$ProxiesOverviewNotifier with AppLogger {
|
||||
@override
|
||||
Stream<List<ProxyGroupEntity>> build() async* {
|
||||
ref.disposeDelay(const Duration(seconds: 15));
|
||||
final serviceRunning = await ref.watch(serviceRunningProvider.future);
|
||||
if (!serviceRunning) {
|
||||
throw const ServiceNotRunning();
|
||||
}
|
||||
final sortBy = ref.watch(proxiesSortNotifierProvider);
|
||||
yield* ref
|
||||
.watch(proxyRepositoryProvider)
|
||||
.watchProxies()
|
||||
.throttleTime(
|
||||
const Duration(milliseconds: 100),
|
||||
leading: false,
|
||||
trailing: true,
|
||||
)
|
||||
.map(
|
||||
(event) => event.getOrElse(
|
||||
(err) {
|
||||
loggy.warning("error receiving proxies", err);
|
||||
throw err;
|
||||
},
|
||||
),
|
||||
)
|
||||
.asyncMap((proxies) async => _sortOutbounds(proxies, sortBy));
|
||||
}
|
||||
|
||||
Future<List<ProxyGroupEntity>> _sortOutbounds(
|
||||
List<ProxyGroupEntity> proxies,
|
||||
ProxiesSort sortBy,
|
||||
) async {
|
||||
return CombineWorker().execute(
|
||||
() {
|
||||
final groupWithSelected = {
|
||||
for (final o in proxies) o.tag: o.selected,
|
||||
};
|
||||
final sortedProxies = <ProxyGroupEntity>[];
|
||||
for (final group in proxies) {
|
||||
final sortedItems = switch (sortBy) {
|
||||
ProxiesSort.name => group.items.sortedBy((e) => e.tag),
|
||||
ProxiesSort.delay => group.items.sortedWith((a, b) {
|
||||
final ai = a.urlTestDelay;
|
||||
final bi = b.urlTestDelay;
|
||||
if (ai == 0 && bi == 0) return -1;
|
||||
if (ai == 0 && bi > 0) return 1;
|
||||
if (ai > 0 && bi == 0) return -1;
|
||||
if (ai == bi && a.type.isGroup) return -1;
|
||||
return ai.compareTo(bi);
|
||||
}),
|
||||
ProxiesSort.unsorted => group.items,
|
||||
};
|
||||
final items = <ProxyItemEntity>[];
|
||||
for (final item in sortedItems) {
|
||||
if (groupWithSelected.keys.contains(item.tag)) {
|
||||
items
|
||||
.add(item.copyWith(selectedTag: groupWithSelected[item.tag]));
|
||||
} else {
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
sortedProxies.add(group.copyWith(items: items));
|
||||
}
|
||||
return sortedProxies;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> changeProxy(String groupTag, String outboundTag) async {
|
||||
loggy.debug(
|
||||
"changing proxy, group: [$groupTag] - outbound: [$outboundTag]",
|
||||
);
|
||||
if (state case AsyncData(value: final outbounds)) {
|
||||
await ref
|
||||
.read(proxyRepositoryProvider)
|
||||
.selectProxy(groupTag, outboundTag)
|
||||
.getOrElse((err) {
|
||||
loggy.warning("error selecting outbound", err);
|
||||
throw err;
|
||||
}).run();
|
||||
state = AsyncData(
|
||||
[
|
||||
...outbounds.map(
|
||||
(e) => e.tag == groupTag ? e.copyWith(selected: outboundTag) : e,
|
||||
),
|
||||
],
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> urlTest(String groupTag) async {
|
||||
loggy.debug("testing group: [$groupTag]");
|
||||
if (state case AsyncData()) {
|
||||
await ref
|
||||
.read(proxyRepositoryProvider)
|
||||
.urlTest(groupTag)
|
||||
.getOrElse((err) {
|
||||
loggy.error("error testing group", err);
|
||||
throw err;
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
171
lib/features/proxy/overview/proxies_overview_page.dart
Normal file
171
lib/features/proxy/overview/proxies_overview_page.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/localization/translations.dart';
|
||||
import 'package:hiddify/core/model/failures.dart';
|
||||
import 'package:hiddify/features/common/nested_app_bar.dart';
|
||||
import 'package:hiddify/features/proxy/overview/proxies_overview_notifier.dart';
|
||||
import 'package:hiddify/features/proxy/widget/proxy_tile.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class ProxiesOverviewPage extends HookConsumerWidget with PresLogger {
|
||||
const ProxiesOverviewPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final asyncProxies = ref.watch(proxiesOverviewNotifierProvider);
|
||||
final notifier = ref.watch(proxiesOverviewNotifierProvider.notifier);
|
||||
final sortBy = ref.watch(proxiesSortNotifierProvider);
|
||||
|
||||
final selectActiveProxyMutation = useMutation(
|
||||
initialOnFailure: (error) =>
|
||||
CustomToast.error(t.presentShortError(error)).show(context),
|
||||
);
|
||||
|
||||
switch (asyncProxies) {
|
||||
case AsyncData(value: final groups):
|
||||
if (groups.isEmpty) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedAppBar(
|
||||
title: Text(t.proxies.pageTitle),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t.proxies.emptyProxiesMsg),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final group = groups.first;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedAppBar(
|
||||
title: Text(t.proxies.pageTitle),
|
||||
actions: [
|
||||
PopupMenuButton<ProxiesSort>(
|
||||
initialValue: sortBy,
|
||||
onSelected:
|
||||
ref.read(proxiesSortNotifierProvider.notifier).update,
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: t.proxies.sortTooltip,
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
...ProxiesSort.values.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: e,
|
||||
child: Text(e.present(t)),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.crossAxisExtent;
|
||||
if (!PlatformUtils.isDesktop && width < 648) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(bottom: 86),
|
||||
sliver: SliverList.builder(
|
||||
itemBuilder: (_, index) {
|
||||
final proxy = group.items[index];
|
||||
return ProxyTile(
|
||||
proxy,
|
||||
selected: group.selected == proxy.tag,
|
||||
onSelect: () async {
|
||||
if (selectActiveProxyMutation
|
||||
.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
selectActiveProxyMutation.setFuture(
|
||||
notifier.changeProxy(group.tag, proxy.tag),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: group.items.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: (width / 268).floor(),
|
||||
mainAxisExtent: 68,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final proxy = group.items[index];
|
||||
return ProxyTile(
|
||||
proxy,
|
||||
selected: group.selected == proxy.tag,
|
||||
onSelect: () async {
|
||||
if (selectActiveProxyMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
selectActiveProxyMutation.setFuture(
|
||||
notifier.changeProxy(
|
||||
group.tag,
|
||||
proxy.tag,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: group.items.length,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async => notifier.urlTest(group.tag),
|
||||
tooltip: t.proxies.delayTestTooltip,
|
||||
child: const Icon(Icons.bolt),
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncError(:final error):
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedAppBar(
|
||||
title: Text(t.proxies.pageTitle),
|
||||
),
|
||||
SliverErrorBodyPlaceholder(
|
||||
t.presentShortError(error),
|
||||
icon: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncLoading():
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedAppBar(
|
||||
title: Text(t.proxies.pageTitle),
|
||||
),
|
||||
const SliverLoadingBodyPlaceholder(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: remove
|
||||
default:
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
}
|
||||
92
lib/features/proxy/widget/proxy_tile.dart
Normal file
92
lib/features/proxy/widget/proxy_tile.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/features/proxy/model/proxy_entity.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class ProxyTile extends HookConsumerWidget with PresLogger {
|
||||
const ProxyTile(
|
||||
this.proxy, {
|
||||
super.key,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final ProxyItemEntity proxy;
|
||||
final bool selected;
|
||||
final VoidCallback onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text(
|
||||
proxy.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: double.maxFinite,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: selected ? theme.colorScheme.primary : Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Text.rich(
|
||||
TextSpan(
|
||||
text: proxy.type.label,
|
||||
children: [
|
||||
if (proxy.selectedName != null)
|
||||
TextSpan(
|
||||
text: ' (${proxy.selectedName})',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: proxy.urlTestDelay != 0
|
||||
? Text(
|
||||
proxy.urlTestDelay.toString(),
|
||||
style: TextStyle(color: delayColor(context, proxy.urlTestDelay)),
|
||||
)
|
||||
: null,
|
||||
selected: selected,
|
||||
onTap: onSelect,
|
||||
onLongPress: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: SelectionArea(child: Text(proxy.name)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
horizontalTitleGap: 4,
|
||||
);
|
||||
}
|
||||
|
||||
Color delayColor(BuildContext context, int delay) {
|
||||
if (Theme.of(context).brightness == Brightness.dark) {
|
||||
return switch (delay) {
|
||||
< 800 => Colors.lightGreen,
|
||||
< 1500 => Colors.orange,
|
||||
_ => Colors.redAccent
|
||||
};
|
||||
}
|
||||
return switch (delay) {
|
||||
< 800 => Colors.green,
|
||||
< 1500 => Colors.deepOrangeAccent,
|
||||
_ => Colors.red
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user