This commit is contained in:
problematicconsumer
2023-12-01 12:56:24 +03:30
parent 9c165e178b
commit ed614988a2
181 changed files with 3092 additions and 2341 deletions

View 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),
);
}

View 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,
);
}
}

View 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();

View 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,
),
};
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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
};
}
}