initial
This commit is contained in:
80
lib/bootstrap.dart
Normal file
80
lib/bootstrap.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:hiddify/core/app/app.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/system_tray/system_tray.dart';
|
||||
import 'package:hiddify/services/deep_link_service.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:loggy/loggy.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:stack_trace/stack_trace.dart' as stack_trace;
|
||||
|
||||
final _loggy = Loggy('bootstrap');
|
||||
final _stopWatch = Stopwatch();
|
||||
|
||||
Future<void> lazyBootstrap(WidgetsBinding widgetsBinding) async {
|
||||
_stopWatch.start();
|
||||
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
|
||||
// temporary solution: https://github.com/rrousselGit/riverpod/issues/1874
|
||||
FlutterError.demangleStackTrace = (StackTrace stack) {
|
||||
if (stack is stack_trace.Trace) return stack.vmTrace;
|
||||
if (stack is stack_trace.Chain) return stack.toTrace().vmTrace;
|
||||
return stack;
|
||||
};
|
||||
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final container = ProviderContainer(
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(sharedPreferences)],
|
||||
);
|
||||
|
||||
Loggy.initLoggy(logPrinter: const PrettyPrinter());
|
||||
|
||||
await initAppServices(container.read);
|
||||
await initControllers(container.read);
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
parent: container,
|
||||
child: const AppView(),
|
||||
),
|
||||
);
|
||||
|
||||
FlutterNativeSplash.remove();
|
||||
_stopWatch.stop();
|
||||
_loggy.debug("bootstrapping took [${_stopWatch.elapsedMilliseconds}]ms");
|
||||
}
|
||||
|
||||
Future<void> initAppServices(
|
||||
Result Function<Result>(ProviderListenable<Result>) read,
|
||||
) async {
|
||||
await read(filesEditorServiceProvider).init();
|
||||
await Future.wait(
|
||||
[
|
||||
read(connectivityServiceProvider).init(),
|
||||
read(clashServiceProvider).init(),
|
||||
read(clashServiceProvider).start(),
|
||||
read(notificationServiceProvider).init(),
|
||||
if (PlatformUtils.isDesktop) read(windowManagerServiceProvider).init(),
|
||||
],
|
||||
);
|
||||
_loggy.debug('initialized app services');
|
||||
}
|
||||
|
||||
Future<void> initControllers(
|
||||
Result Function<Result>(ProviderListenable<Result>) read,
|
||||
) async {
|
||||
await Future.wait(
|
||||
[
|
||||
read(activeProfileProvider.future),
|
||||
read(deepLinkServiceProvider.future),
|
||||
if (PlatformUtils.isDesktop) read(systemTrayControllerProvider.future),
|
||||
],
|
||||
);
|
||||
_loggy.debug("initialized base controllers");
|
||||
}
|
||||
1
lib/core/app/app.dart
Normal file
1
lib/core/app/app.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'app_view.dart';
|
||||
31
lib/core/app/app_view.dart
Normal file
31
lib/core/app/app_view.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/core/theme/theme.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class AppView extends HookConsumerWidget with PresLogger {
|
||||
const AppView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final locale = ref.watch(localeControllerProvider).locale;
|
||||
final theme = ref.watch(themeControllerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
routerConfig: router,
|
||||
locale: locale,
|
||||
supportedLocales: LocalePref.locales,
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: theme.themeMode,
|
||||
theme: theme.light,
|
||||
darkTheme: theme.dark,
|
||||
title: 'Hiddify',
|
||||
).animate().fadeIn();
|
||||
}
|
||||
}
|
||||
8
lib/core/core_providers.dart
Normal file
8
lib/core/core_providers.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'core_providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TranslationsEn translations(TranslationsRef ref) =>
|
||||
ref.watch(localeControllerProvider).translations();
|
||||
2
lib/core/locale/locale.dart
Normal file
2
lib/core/locale/locale.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'locale_controller.dart';
|
||||
export 'locale_pref.dart';
|
||||
24
lib/core/locale/locale_controller.dart
Normal file
24
lib/core/locale/locale_controller.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:hiddify/core/locale/locale_pref.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'locale_controller.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class LocaleController extends _$LocaleController with AppLogger {
|
||||
@override
|
||||
LocalePref build() {
|
||||
return LocalePref.values[_prefs.getInt(_localeKey) ?? 0];
|
||||
}
|
||||
|
||||
static const _localeKey = 'locale';
|
||||
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
|
||||
|
||||
Future<void> change(LocalePref locale) async {
|
||||
loggy.debug('changing locale to [$locale]');
|
||||
await _prefs.setInt(_localeKey, locale.index);
|
||||
state = locale;
|
||||
}
|
||||
}
|
||||
32
lib/core/locale/locale_pref.dart
Normal file
32
lib/core/locale/locale_pref.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hiddify/gen/translations.g.dart';
|
||||
|
||||
export 'package:hiddify/gen/translations.g.dart';
|
||||
|
||||
enum LocalePref {
|
||||
en;
|
||||
|
||||
Locale get locale {
|
||||
return Locale(name);
|
||||
}
|
||||
|
||||
static List<Locale> get locales =>
|
||||
LocalePref.values.map((e) => e.locale).toList();
|
||||
|
||||
static LocalePref fromString(String e) {
|
||||
return LocalePref.values.firstOrNullWhere((element) => element.name == e) ??
|
||||
LocalePref.en;
|
||||
}
|
||||
|
||||
static LocalePref deviceLocale() {
|
||||
return LocalePref.fromString(
|
||||
AppLocaleUtils.findDeviceLocale().languageCode,
|
||||
);
|
||||
}
|
||||
|
||||
TranslationsEn translations() {
|
||||
final appLocale = AppLocaleUtils.parse(name);
|
||||
return appLocale.build();
|
||||
}
|
||||
}
|
||||
2
lib/core/prefs/prefs.dart
Normal file
2
lib/core/prefs/prefs.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'prefs_controller.dart';
|
||||
export 'prefs_state.dart';
|
||||
58
lib/core/prefs/prefs_controller.dart
Normal file
58
lib/core/prefs/prefs_controller.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hiddify/core/prefs/prefs_state.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'prefs_controller.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class PrefsController extends _$PrefsController with AppLogger {
|
||||
@override
|
||||
PrefsState build() {
|
||||
return PrefsState(
|
||||
clash: _getClashPrefs(),
|
||||
network: _getNetworkPrefs(),
|
||||
);
|
||||
}
|
||||
|
||||
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
|
||||
|
||||
static const _overridesKey = "clash_overrides";
|
||||
static const _networkKey = "clash_overrides";
|
||||
|
||||
ClashConfig _getClashPrefs() {
|
||||
final persisted = _prefs.getString(_overridesKey);
|
||||
if (persisted == null) return ClashConfig.initial;
|
||||
return ClashConfig.fromJson(jsonDecode(persisted) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
NetworkPrefs _getNetworkPrefs() {
|
||||
final persisted = _prefs.getString(_networkKey);
|
||||
if (persisted == null) return const NetworkPrefs();
|
||||
return NetworkPrefs.fromJson(jsonDecode(persisted) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> patchClashOverrides(ClashConfigPatch overrides) async {
|
||||
final newPrefs = state.clash.patch(overrides);
|
||||
await _prefs.setString(_overridesKey, jsonEncode(newPrefs.toJson()));
|
||||
state = state.copyWith(clash: newPrefs);
|
||||
}
|
||||
|
||||
Future<void> patchNetworkPrefs({
|
||||
bool? systemProxy,
|
||||
bool? bypassPrivateNetworks,
|
||||
}) async {
|
||||
final newPrefs = state.network.copyWith(
|
||||
systemProxy: systemProxy ?? state.network.systemProxy,
|
||||
bypassPrivateNetworks:
|
||||
bypassPrivateNetworks ?? state.network.bypassPrivateNetworks,
|
||||
);
|
||||
await _prefs.setString(_networkKey, jsonEncode(newPrefs.toJson()));
|
||||
state = state.copyWith(network: newPrefs);
|
||||
}
|
||||
}
|
||||
15
lib/core/prefs/prefs_state.dart
Normal file
15
lib/core/prefs/prefs_state.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
|
||||
part 'prefs_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class PrefsState with _$PrefsState {
|
||||
const PrefsState._();
|
||||
|
||||
const factory PrefsState({
|
||||
@Default(ClashConfig()) ClashConfig clash,
|
||||
@Default(NetworkPrefs()) NetworkPrefs network,
|
||||
}) = _PrefsState;
|
||||
}
|
||||
56
lib/core/router/app_router.dart
Normal file
56
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/router/routes/routes.dart';
|
||||
import 'package:hiddify/services/deep_link_service.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'app_router.g.dart';
|
||||
|
||||
// TODO: test and improve handling of deep link
|
||||
@riverpod
|
||||
GoRouter router(RouterRef ref) {
|
||||
final deepLink = ref.listen(
|
||||
deepLinkServiceProvider,
|
||||
(_, next) async {
|
||||
if (next case AsyncData(value: final link?)) {
|
||||
await ref.state.push(
|
||||
NewProfileRoute(url: link.url, name: link.name).location,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
final initialLink = deepLink.read();
|
||||
String initialLocation = HomeRoute.path;
|
||||
if (initialLink case AsyncData(value: final link?)) {
|
||||
initialLocation = NewProfileRoute(url: link.url, name: link.name).location;
|
||||
}
|
||||
|
||||
return GoRouter(
|
||||
navigatorKey: rootNavigatorKey,
|
||||
initialLocation: initialLocation,
|
||||
debugLogDiagnostics: true,
|
||||
routes: $routes,
|
||||
);
|
||||
}
|
||||
|
||||
int getCurrentIndex(BuildContext context) {
|
||||
final String location = GoRouterState.of(context).location;
|
||||
if (location == HomeRoute.path) return 0;
|
||||
if (location.startsWith(ProxiesRoute.path)) return 1;
|
||||
if (location.startsWith(LogsRoute.path)) return 2;
|
||||
if (location.startsWith(SettingsRoute.path)) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void switchTab(int index, BuildContext context) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
const HomeRoute().go(context);
|
||||
case 1:
|
||||
const ProxiesRoute().go(context);
|
||||
case 2:
|
||||
const LogsRoute().go(context);
|
||||
case 3:
|
||||
const SettingsRoute().go(context);
|
||||
}
|
||||
}
|
||||
2
lib/core/router/router.dart
Normal file
2
lib/core/router/router.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'app_router.dart';
|
||||
export 'routes/routes.dart';
|
||||
52
lib/core/router/routes/desktop_routes.dart
Normal file
52
lib/core/router/routes/desktop_routes.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/router/routes/shared_routes.dart';
|
||||
import 'package:hiddify/features/logs/view/view.dart';
|
||||
import 'package:hiddify/features/settings/view/view.dart';
|
||||
import 'package:hiddify/features/wrapper/wrapper.dart';
|
||||
|
||||
part 'desktop_routes.g.dart';
|
||||
|
||||
@TypedShellRoute<DesktopWrapperRoute>(
|
||||
routes: [
|
||||
TypedGoRoute<HomeRoute>(
|
||||
path: HomeRoute.path,
|
||||
routes: [
|
||||
TypedGoRoute<ProfilesRoute>(path: ProfilesRoute.path),
|
||||
TypedGoRoute<NewProfileRoute>(path: NewProfileRoute.path),
|
||||
TypedGoRoute<ProfileDetailsRoute>(path: ProfileDetailsRoute.path),
|
||||
],
|
||||
),
|
||||
TypedGoRoute<ProxiesRoute>(path: ProxiesRoute.path),
|
||||
TypedGoRoute<LogsRoute>(path: LogsRoute.path),
|
||||
TypedGoRoute<SettingsRoute>(path: SettingsRoute.path),
|
||||
],
|
||||
)
|
||||
class DesktopWrapperRoute extends ShellRouteData {
|
||||
const DesktopWrapperRoute();
|
||||
|
||||
@override
|
||||
Widget builder(BuildContext context, GoRouterState state, Widget navigator) {
|
||||
return DesktopWrapper(navigator);
|
||||
}
|
||||
}
|
||||
|
||||
class LogsRoute extends GoRouteData {
|
||||
const LogsRoute();
|
||||
static const path = '/logs';
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return const NoTransitionPage(child: LogsPage());
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsRoute extends GoRouteData {
|
||||
const SettingsRoute();
|
||||
static const path = '/settings';
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return const NoTransitionPage(child: SettingsPage());
|
||||
}
|
||||
}
|
||||
62
lib/core/router/routes/mobile_routes.dart
Normal file
62
lib/core/router/routes/mobile_routes.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/router/routes/shared_routes.dart';
|
||||
import 'package:hiddify/features/logs/view/view.dart';
|
||||
import 'package:hiddify/features/settings/view/view.dart';
|
||||
import 'package:hiddify/features/wrapper/wrapper.dart';
|
||||
|
||||
part 'mobile_routes.g.dart';
|
||||
|
||||
@TypedShellRoute<MobileWrapperRoute>(
|
||||
routes: [
|
||||
TypedGoRoute<HomeRoute>(
|
||||
path: HomeRoute.path,
|
||||
routes: [
|
||||
TypedGoRoute<ProfilesRoute>(path: ProfilesRoute.path),
|
||||
TypedGoRoute<NewProfileRoute>(path: NewProfileRoute.path),
|
||||
TypedGoRoute<ProfileDetailsRoute>(path: ProfileDetailsRoute.path),
|
||||
],
|
||||
),
|
||||
TypedGoRoute<ProxiesRoute>(path: ProxiesRoute.path),
|
||||
],
|
||||
)
|
||||
class MobileWrapperRoute extends ShellRouteData {
|
||||
const MobileWrapperRoute();
|
||||
|
||||
@override
|
||||
Widget builder(BuildContext context, GoRouterState state, Widget navigator) {
|
||||
return MobileWrapper(navigator);
|
||||
}
|
||||
}
|
||||
|
||||
@TypedGoRoute<LogsRoute>(path: LogsRoute.path)
|
||||
class LogsRoute extends GoRouteData {
|
||||
const LogsRoute();
|
||||
static const path = '/logs';
|
||||
|
||||
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return const MaterialPage(
|
||||
fullscreenDialog: true,
|
||||
child: LogsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@TypedGoRoute<SettingsRoute>(path: SettingsRoute.path)
|
||||
class SettingsRoute extends GoRouteData {
|
||||
const SettingsRoute();
|
||||
static const path = '/settings';
|
||||
|
||||
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return const MaterialPage(
|
||||
fullscreenDialog: true,
|
||||
child: SettingsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/core/router/routes/routes.dart
Normal file
16
lib/core/router/routes/routes.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/router/routes/desktop_routes.dart' as desktop;
|
||||
import 'package:hiddify/core/router/routes/mobile_routes.dart' as mobile;
|
||||
import 'package:hiddify/core/router/routes/shared_routes.dart' as shared;
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
export 'mobile_routes.dart';
|
||||
export 'shared_routes.dart' hide $appRoutes;
|
||||
|
||||
List<RouteBase> get $routes => [
|
||||
if (PlatformUtils.isDesktop)
|
||||
...desktop.$appRoutes
|
||||
else
|
||||
...mobile.$appRoutes,
|
||||
...shared.$appRoutes,
|
||||
];
|
||||
101
lib/core/router/routes/shared_routes.dart
Normal file
101
lib/core/router/routes/shared_routes.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/home/view/view.dart';
|
||||
import 'package:hiddify/features/profile_detail/view/view.dart';
|
||||
import 'package:hiddify/features/profiles/view/view.dart';
|
||||
import 'package:hiddify/features/proxies/view/view.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'shared_routes.g.dart';
|
||||
|
||||
List<RouteBase> get $sharedRoutes => $appRoutes;
|
||||
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
class HomeRoute extends GoRouteData {
|
||||
const HomeRoute();
|
||||
static const path = '/';
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return const NoTransitionPage(child: HomePage());
|
||||
}
|
||||
}
|
||||
|
||||
class ProxiesRoute extends GoRouteData {
|
||||
const ProxiesRoute();
|
||||
static const path = '/proxies';
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return const NoTransitionPage(child: ProxiesPage());
|
||||
}
|
||||
}
|
||||
|
||||
@TypedGoRoute<AddProfileRoute>(path: AddProfileRoute.path)
|
||||
class AddProfileRoute extends GoRouteData {
|
||||
const AddProfileRoute();
|
||||
static const path = '/add';
|
||||
|
||||
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return BottomSheetPage(
|
||||
fixed: true,
|
||||
builder: (controller) => AddProfileModal(scrollController: controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfilesRoute extends GoRouteData {
|
||||
const ProfilesRoute();
|
||||
static const path = 'profiles';
|
||||
|
||||
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return BottomSheetPage(
|
||||
builder: (controller) => ProfilesModal(scrollController: controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NewProfileRoute extends GoRouteData {
|
||||
const NewProfileRoute({this.url, this.name});
|
||||
static const path = 'profiles/new';
|
||||
final String? url;
|
||||
final String? name;
|
||||
|
||||
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return MaterialPage(
|
||||
fullscreenDialog: true,
|
||||
child: ProfileDetailPage(
|
||||
"new",
|
||||
url: url,
|
||||
name: name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileDetailsRoute extends GoRouteData {
|
||||
const ProfileDetailsRoute(this.id);
|
||||
final String id;
|
||||
static const path = 'profiles/:id';
|
||||
|
||||
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return MaterialPage(
|
||||
fullscreenDialog: true,
|
||||
child: ProfileDetailPage(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/core/theme/app_theme.dart
Normal file
123
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:flex_color_scheme/flex_color_scheme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/theme/theme_prefs.dart';
|
||||
|
||||
// mostly exact copy of flex color scheme 7.1's fabulous 12 theme
|
||||
extension AppTheme on ThemePrefs {
|
||||
ThemeData get light {
|
||||
return FlexThemeData.light(
|
||||
scheme: FlexScheme.indigoM3,
|
||||
surfaceMode: FlexSurfaceMode.highScaffoldLowSurface,
|
||||
useMaterial3: true,
|
||||
swapLegacyOnMaterial3: true,
|
||||
useMaterial3ErrorColors: true,
|
||||
blendLevel: 1,
|
||||
subThemesData: const FlexSubThemesData(
|
||||
useTextTheme: true,
|
||||
useM2StyleDividerInM3: true,
|
||||
elevatedButtonSchemeColor: SchemeColor.onPrimaryContainer,
|
||||
elevatedButtonSecondarySchemeColor: SchemeColor.primaryContainer,
|
||||
outlinedButtonOutlineSchemeColor: SchemeColor.primary,
|
||||
toggleButtonsBorderSchemeColor: SchemeColor.primary,
|
||||
segmentedButtonSchemeColor: SchemeColor.primary,
|
||||
segmentedButtonBorderSchemeColor: SchemeColor.primary,
|
||||
unselectedToggleIsColored: true,
|
||||
sliderValueTinted: true,
|
||||
inputDecoratorSchemeColor: SchemeColor.primary,
|
||||
inputDecoratorBackgroundAlpha: 43,
|
||||
inputDecoratorUnfocusedHasBorder: false,
|
||||
inputDecoratorFocusedBorderWidth: 1.0,
|
||||
inputDecoratorPrefixIconSchemeColor: SchemeColor.primary,
|
||||
popupMenuRadius: 8.0,
|
||||
popupMenuElevation: 3.0,
|
||||
drawerIndicatorSchemeColor: SchemeColor.primary,
|
||||
bottomNavigationBarMutedUnselectedLabel: false,
|
||||
bottomNavigationBarMutedUnselectedIcon: false,
|
||||
menuRadius: 8.0,
|
||||
menuElevation: 3.0,
|
||||
menuBarRadius: 0.0,
|
||||
menuBarElevation: 2.0,
|
||||
menuBarShadowColor: Color(0x00000000),
|
||||
navigationBarSelectedLabelSchemeColor: SchemeColor.primary,
|
||||
navigationBarMutedUnselectedLabel: false,
|
||||
navigationBarSelectedIconSchemeColor: SchemeColor.onPrimary,
|
||||
navigationBarMutedUnselectedIcon: false,
|
||||
navigationBarIndicatorSchemeColor: SchemeColor.primary,
|
||||
navigationBarIndicatorOpacity: 1.00,
|
||||
navigationRailSelectedLabelSchemeColor: SchemeColor.primary,
|
||||
navigationRailMutedUnselectedLabel: false,
|
||||
navigationRailSelectedIconSchemeColor: SchemeColor.onPrimary,
|
||||
navigationRailMutedUnselectedIcon: false,
|
||||
navigationRailIndicatorSchemeColor: SchemeColor.primary,
|
||||
navigationRailIndicatorOpacity: 1.00,
|
||||
navigationRailBackgroundSchemeColor: SchemeColor.surface,
|
||||
),
|
||||
keyColors: const FlexKeyColors(
|
||||
useSecondary: true,
|
||||
useTertiary: true,
|
||||
keepPrimary: true,
|
||||
),
|
||||
tones: FlexTones.jolly(Brightness.light),
|
||||
visualDensity: FlexColorScheme.comfortablePlatformDensity,
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData get dark {
|
||||
return FlexThemeData.dark(
|
||||
scheme: FlexScheme.indigoM3,
|
||||
useMaterial3: true,
|
||||
swapLegacyOnMaterial3: true,
|
||||
useMaterial3ErrorColors: true,
|
||||
darkIsTrueBlack: trueBlack,
|
||||
surfaceMode: FlexSurfaceMode.highScaffoldLowSurface,
|
||||
// blendLevel: 1,
|
||||
subThemesData: const FlexSubThemesData(
|
||||
blendTextTheme: true,
|
||||
useTextTheme: true,
|
||||
useM2StyleDividerInM3: true,
|
||||
elevatedButtonSchemeColor: SchemeColor.onPrimaryContainer,
|
||||
elevatedButtonSecondarySchemeColor: SchemeColor.primaryContainer,
|
||||
outlinedButtonOutlineSchemeColor: SchemeColor.primary,
|
||||
toggleButtonsBorderSchemeColor: SchemeColor.primary,
|
||||
segmentedButtonSchemeColor: SchemeColor.primary,
|
||||
segmentedButtonBorderSchemeColor: SchemeColor.primary,
|
||||
unselectedToggleIsColored: true,
|
||||
sliderValueTinted: true,
|
||||
inputDecoratorSchemeColor: SchemeColor.primary,
|
||||
inputDecoratorBackgroundAlpha: 43,
|
||||
inputDecoratorUnfocusedHasBorder: false,
|
||||
inputDecoratorFocusedBorderWidth: 1.0,
|
||||
inputDecoratorPrefixIconSchemeColor: SchemeColor.primary,
|
||||
popupMenuRadius: 8.0,
|
||||
popupMenuElevation: 3.0,
|
||||
drawerIndicatorSchemeColor: SchemeColor.primary,
|
||||
bottomNavigationBarMutedUnselectedLabel: false,
|
||||
bottomNavigationBarMutedUnselectedIcon: false,
|
||||
menuRadius: 8.0,
|
||||
menuElevation: 3.0,
|
||||
menuBarRadius: 0.0,
|
||||
menuBarElevation: 2.0,
|
||||
menuBarShadowColor: Color(0x00000000),
|
||||
navigationBarSelectedLabelSchemeColor: SchemeColor.primary,
|
||||
navigationBarMutedUnselectedLabel: false,
|
||||
navigationBarSelectedIconSchemeColor: SchemeColor.onPrimary,
|
||||
navigationBarMutedUnselectedIcon: false,
|
||||
navigationBarIndicatorSchemeColor: SchemeColor.primary,
|
||||
navigationBarIndicatorOpacity: 1.00,
|
||||
navigationRailSelectedLabelSchemeColor: SchemeColor.primary,
|
||||
navigationRailMutedUnselectedLabel: false,
|
||||
navigationRailSelectedIconSchemeColor: SchemeColor.onPrimary,
|
||||
navigationRailMutedUnselectedIcon: false,
|
||||
navigationRailIndicatorSchemeColor: SchemeColor.primary,
|
||||
navigationRailIndicatorOpacity: 1.00,
|
||||
navigationRailBackgroundSchemeColor: SchemeColor.surface,
|
||||
),
|
||||
keyColors: const FlexKeyColors(
|
||||
useSecondary: true,
|
||||
useTertiary: true,
|
||||
),
|
||||
// tones: FlexTones.jolly(Brightness.dark),
|
||||
visualDensity: FlexColorScheme.comfortablePlatformDensity,
|
||||
);
|
||||
}
|
||||
}
|
||||
6
lib/core/theme/constants.dart
Normal file
6
lib/core/theme/constants.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class ConnectionButtonColor {
|
||||
static const connected = Color.fromRGBO(89, 140, 82, 1);
|
||||
static const disconnected = Color.fromRGBO(74, 77, 139, 1);
|
||||
}
|
||||
4
lib/core/theme/theme.dart
Normal file
4
lib/core/theme/theme.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'app_theme.dart';
|
||||
export 'constants.dart';
|
||||
export 'theme_controller.dart';
|
||||
export 'theme_prefs.dart';
|
||||
41
lib/core/theme/theme_controller.dart
Normal file
41
lib/core/theme/theme_controller.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/theme/theme_prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'theme_controller.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ThemeController extends _$ThemeController with AppLogger {
|
||||
@override
|
||||
ThemePrefs build() {
|
||||
return ThemePrefs(
|
||||
themeMode: ThemeMode.values[_prefs.getInt(_themeModeKey) ?? 0],
|
||||
trueBlack: _prefs.getBool(_trueBlackKey) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
|
||||
|
||||
static const _themeModeKey = "theme_mode";
|
||||
static const _trueBlackKey = "true_black";
|
||||
|
||||
Future<void> change({
|
||||
ThemeMode? themeMode,
|
||||
bool? trueBlack,
|
||||
}) async {
|
||||
loggy.debug('changing theme, mode=$themeMode, trueBlack=$trueBlack');
|
||||
if (themeMode != null) {
|
||||
await _prefs.setInt(_themeModeKey, themeMode.index);
|
||||
}
|
||||
if (trueBlack != null) {
|
||||
await _prefs.setBool(_trueBlackKey, trueBlack);
|
||||
}
|
||||
state = state.copyWith(
|
||||
themeMode: themeMode ?? state.themeMode,
|
||||
trueBlack: trueBlack ?? state.trueBlack,
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/core/theme/theme_prefs.dart
Normal file
14
lib/core/theme/theme_prefs.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'theme_prefs.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ThemePrefs with _$ThemePrefs {
|
||||
const ThemePrefs._();
|
||||
|
||||
const factory ThemePrefs({
|
||||
@Default(ThemeMode.system) ThemeMode themeMode,
|
||||
@Default(false) bool trueBlack,
|
||||
}) = _ThemePrefs;
|
||||
}
|
||||
42
lib/data/data_providers.dart
Normal file
42
lib/data/data_providers.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hiddify/data/local/dao/dao.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/data/repository/repository.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'data_providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
AppDatabase appDatabase(AppDatabaseRef ref) => AppDatabase.connect();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SharedPreferences sharedPreferences(SharedPreferencesRef ref) =>
|
||||
throw UnimplementedError('sharedPreferences must be overridden');
|
||||
|
||||
// TODO: set options for dio
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio dio(DioRef ref) => Dio();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfilesDao profilesDao(ProfilesDaoRef ref) => ProfilesDao(
|
||||
ref.watch(appDatabaseProvider),
|
||||
);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ClashFacade clashFacade(ClashFacadeRef ref) => ClashFacadeImpl(
|
||||
clashService: ref.watch(clashServiceProvider),
|
||||
filesEditor: ref.watch(filesEditorServiceProvider),
|
||||
);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfilesRepository profilesRepository(ProfilesRepositoryRef ref) =>
|
||||
ProfilesRepositoryImpl(
|
||||
profilesDao: ref.watch(profilesDaoProvider),
|
||||
filesEditor: ref.watch(filesEditorServiceProvider),
|
||||
clashFacade: ref.watch(clashFacadeProvider),
|
||||
dio: ref.watch(dioProvider),
|
||||
);
|
||||
1
lib/data/local/dao/dao.dart
Normal file
1
lib/data/local/dao/dao.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profiles_dao.dart';
|
||||
83
lib/data/local/dao/profiles_dao.dart
Normal file
83
lib/data/local/dao/profiles_dao.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/data_mappers.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/data/local/tables.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'profiles_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [ProfileEntries])
|
||||
class ProfilesDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$ProfilesDaoMixin, InfraLogger {
|
||||
ProfilesDao(super.db);
|
||||
|
||||
Future<Profile?> getById(String id) async {
|
||||
return (profileEntries.select()..where((tbl) => tbl.id.equals(id)))
|
||||
.map(ProfileMapper.fromEntry)
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Stream<Profile?> watchActiveProfile() {
|
||||
return (profileEntries.select()..where((tbl) => tbl.active.equals(true)))
|
||||
.map(ProfileMapper.fromEntry)
|
||||
.watchSingleOrNull();
|
||||
}
|
||||
|
||||
Stream<int> watchProfileCount() {
|
||||
final count = profileEntries.id.count();
|
||||
return (profileEntries.selectOnly()..addColumns([count]))
|
||||
.map((exp) => exp.read(count)!)
|
||||
.watchSingle();
|
||||
}
|
||||
|
||||
Stream<List<Profile>> watchAll() {
|
||||
return (profileEntries.select()
|
||||
..orderBy(
|
||||
[(tbl) => OrderingTerm.desc(tbl.active)],
|
||||
))
|
||||
.map(ProfileMapper.fromEntry)
|
||||
.watch();
|
||||
}
|
||||
|
||||
Future<void> create(Profile profile) async {
|
||||
await transaction(
|
||||
() async {
|
||||
if (profile.active) {
|
||||
await (update(profileEntries)
|
||||
..where((tbl) => tbl.id.isNotValue(profile.id)))
|
||||
.write(const ProfileEntriesCompanion(active: Value(false)));
|
||||
}
|
||||
await into(profileEntries).insert(profile.toCompanion());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> edit(Profile patch) async {
|
||||
await transaction(
|
||||
() async {
|
||||
await (update(profileEntries)..where((tbl) => tbl.id.equals(patch.id)))
|
||||
.write(patch.toCompanion());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAsActive(String id) async {
|
||||
await transaction(
|
||||
() async {
|
||||
await (update(profileEntries)..where((tbl) => tbl.id.isNotValue(id)))
|
||||
.write(const ProfileEntriesCompanion(active: Value(false)));
|
||||
await (update(profileEntries)..where((tbl) => tbl.id.equals(id)))
|
||||
.write(const ProfileEntriesCompanion(active: Value(true)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeById(String id) async {
|
||||
await transaction(
|
||||
() async {
|
||||
await (delete(profileEntries)..where((tbl) => tbl.id.equals(id))).go();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/data/local/data_mappers.dart
Normal file
37
lib/data/local/data_mappers.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
|
||||
extension ProfileMapper on Profile {
|
||||
ProfileEntriesCompanion toCompanion() {
|
||||
return ProfileEntriesCompanion.insert(
|
||||
id: id,
|
||||
active: active,
|
||||
name: name,
|
||||
url: url,
|
||||
lastUpdate: lastUpdate,
|
||||
upload: Value(subInfo?.upload),
|
||||
download: Value(subInfo?.download),
|
||||
total: Value(subInfo?.total),
|
||||
expire: Value(subInfo?.expire),
|
||||
updateInterval: Value(updateInterval),
|
||||
);
|
||||
}
|
||||
|
||||
static Profile fromEntry(ProfileEntry entry) {
|
||||
return Profile(
|
||||
id: entry.id,
|
||||
active: entry.active,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
lastUpdate: entry.lastUpdate,
|
||||
updateInterval: entry.updateInterval,
|
||||
subInfo: SubscriptionInfo(
|
||||
upload: entry.upload,
|
||||
download: entry.download,
|
||||
total: entry.total,
|
||||
expire: entry.expire,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/data/local/database.dart
Normal file
29
lib/data/local/database.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:hiddify/data/local/dao/dao.dart';
|
||||
import 'package:hiddify/data/local/tables.dart';
|
||||
import 'package:hiddify/data/local/type_converters.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [ProfileEntries], daos: [ProfilesDao])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase({required QueryExecutor connection}) : super(connection);
|
||||
|
||||
AppDatabase.connect() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, 'db.sqlite'));
|
||||
return NativeDatabase.createInBackground(file);
|
||||
});
|
||||
}
|
||||
20
lib/data/local/tables.dart
Normal file
20
lib/data/local/tables.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/type_converters.dart';
|
||||
|
||||
@DataClassName('ProfileEntry')
|
||||
class ProfileEntries extends Table {
|
||||
TextColumn get id => text()();
|
||||
BoolColumn get active => boolean()();
|
||||
TextColumn get name => text().withLength(min: 1)();
|
||||
TextColumn get url => text()();
|
||||
IntColumn get upload => integer().nullable()();
|
||||
IntColumn get download => integer().nullable()();
|
||||
IntColumn get total => integer().nullable()();
|
||||
DateTimeColumn get expire => dateTime().nullable()();
|
||||
IntColumn get updateInterval =>
|
||||
integer().nullable().map(DurationTypeConverter())();
|
||||
DateTimeColumn get lastUpdate => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
13
lib/data/local/type_converters.dart
Normal file
13
lib/data/local/type_converters.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
class DurationTypeConverter extends TypeConverter<Duration, int> {
|
||||
@override
|
||||
Duration fromSql(int fromDb) {
|
||||
return Duration(seconds: fromDb);
|
||||
}
|
||||
|
||||
@override
|
||||
int toSql(Duration value) {
|
||||
return value.inSeconds;
|
||||
}
|
||||
}
|
||||
116
lib/data/repository/clash_facade_impl.dart
Normal file
116
lib/data/repository/clash_facade_impl.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/repository/exception_handlers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/constants.dart';
|
||||
import 'package:hiddify/services/clash/clash.dart';
|
||||
import 'package:hiddify/services/files_editor_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
class ClashFacadeImpl
|
||||
with ExceptionHandler, InfraLogger
|
||||
implements ClashFacade {
|
||||
ClashFacadeImpl({
|
||||
required ClashService clashService,
|
||||
required FilesEditorService filesEditor,
|
||||
}) : _clash = clashService,
|
||||
_filesEditor = filesEditor;
|
||||
|
||||
final ClashService _clash;
|
||||
final FilesEditorService _filesEditor;
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, ClashConfig> getConfigs() {
|
||||
return exceptionHandler(
|
||||
() async => _clash.getConfigs().mapLeft(ClashFailure.core).run(),
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, bool> validateConfig(String configFileName) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
final path = _filesEditor.configPath(configFileName);
|
||||
return _clash.validateConfig(path).mapLeft(ClashFailure.core).run();
|
||||
},
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, Unit> changeConfigs(String configFileName) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
loggy.debug("changing config, file name: [$configFileName]");
|
||||
final path = _filesEditor.configPath(configFileName);
|
||||
return _clash.updateConfigs(path).mapLeft(ClashFailure.core).run();
|
||||
},
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, Unit> patchOverrides(ClashConfig overrides) {
|
||||
return exceptionHandler(
|
||||
() async =>
|
||||
_clash.patchConfigs(overrides).mapLeft(ClashFailure.core).run(),
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, List<ClashProxy>> getProxies() {
|
||||
return exceptionHandler(
|
||||
() async => _clash.getProxies().mapLeft(ClashFailure.core).run(),
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, Unit> changeProxy(
|
||||
String selectorName,
|
||||
String proxyName,
|
||||
) {
|
||||
return exceptionHandler(
|
||||
() async => _clash
|
||||
.changeProxy(selectorName, proxyName)
|
||||
.mapLeft(ClashFailure.core)
|
||||
.run(),
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, ClashTraffic> getTraffic() {
|
||||
return exceptionHandler(
|
||||
() async => _clash.getTraffic().mapLeft(ClashFailure.core).run(),
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ClashFailure, int> testDelay(
|
||||
String proxyName, {
|
||||
String testUrl = Constants.delayTestUrl,
|
||||
}) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
final result = _clash
|
||||
.getProxyDelay(proxyName, testUrl)
|
||||
.mapLeft(ClashFailure.core)
|
||||
.run();
|
||||
return result;
|
||||
},
|
||||
ClashFailure.unexpected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ClashFailure, ClashLog>> watchLogs() {
|
||||
return _clash
|
||||
.watchLogs(LogLevel.info)
|
||||
.handleExceptions(ClashFailure.unexpected);
|
||||
}
|
||||
}
|
||||
32
lib/data/repository/exception_handlers.dart
Normal file
32
lib/data/repository/exception_handlers.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
mixin ExceptionHandler implements LoggerMixin {
|
||||
TaskEither<F, R> exceptionHandler<F, R>(
|
||||
Future<Either<F, R>> Function() run,
|
||||
F Function(Object error, StackTrace stackTrace) onError,
|
||||
) {
|
||||
return TaskEither(
|
||||
() async {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error, stackTrace) {
|
||||
return Left(onError(error, stackTrace));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamExceptionHandler<R extends Object?> on Stream<R> {
|
||||
Stream<Either<F, R>> handleExceptions<F>(
|
||||
F Function(Object error, StackTrace stackTrace) onError,
|
||||
) {
|
||||
return map(right<F, R>).onErrorReturnWith(
|
||||
(error, stackTrace) {
|
||||
return Left(onError(error, stackTrace));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
155
lib/data/repository/profiles_repository_impl.dart
Normal file
155
lib/data/repository/profiles_repository_impl.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/local/dao/dao.dart';
|
||||
import 'package:hiddify/data/repository/exception_handlers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/services/files_editor_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
class ProfilesRepositoryImpl
|
||||
with ExceptionHandler, InfraLogger
|
||||
implements ProfilesRepository {
|
||||
ProfilesRepositoryImpl({
|
||||
required this.profilesDao,
|
||||
required this.filesEditor,
|
||||
required this.clashFacade,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
final ProfilesDao profilesDao;
|
||||
final FilesEditorService filesEditor;
|
||||
final ClashFacade clashFacade;
|
||||
final Dio dio;
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Profile?> get(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() => profilesDao.getById(id),
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, Profile?>> watchActiveProfile() {
|
||||
return profilesDao
|
||||
.watchActiveProfile()
|
||||
.handleExceptions(ProfileUnexpectedFailure.new);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile() {
|
||||
return profilesDao
|
||||
.watchProfileCount()
|
||||
.map((event) => event != 0)
|
||||
.handleExceptions(ProfileUnexpectedFailure.new);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, List<Profile>>> watchAll() {
|
||||
return profilesDao
|
||||
.watchAll()
|
||||
.handleExceptions(ProfileUnexpectedFailure.new);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> add(Profile baseProfile) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
return fetch(baseProfile.url, baseProfile.id)
|
||||
.flatMap(
|
||||
(subInfo) => TaskEither(() async {
|
||||
await profilesDao.create(
|
||||
baseProfile.copyWith(
|
||||
subInfo: subInfo,
|
||||
lastUpdate: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return right(unit);
|
||||
}),
|
||||
)
|
||||
.run();
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> update(Profile baseProfile) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
return fetch(baseProfile.url, baseProfile.id)
|
||||
.flatMap(
|
||||
(subInfo) => TaskEither(() async {
|
||||
await profilesDao.edit(
|
||||
baseProfile.copyWith(
|
||||
subInfo: subInfo,
|
||||
lastUpdate: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return right(unit);
|
||||
}),
|
||||
)
|
||||
.run();
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> setAsActive(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() async {
|
||||
await profilesDao.setAsActive(id);
|
||||
return unit;
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> delete(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() async {
|
||||
await profilesDao.removeById(id);
|
||||
await filesEditor.deleteConfig(id);
|
||||
return unit;
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
TaskEither<ProfileFailure, SubscriptionInfo?> fetch(
|
||||
String url,
|
||||
String fileName,
|
||||
) {
|
||||
return TaskEither(
|
||||
() async {
|
||||
final path = filesEditor.configPath(fileName);
|
||||
final response = await dio.download(url, path);
|
||||
if (response.statusCode != 200) {
|
||||
await File(path).delete();
|
||||
return left(const ProfileUnexpectedFailure());
|
||||
}
|
||||
final isValid = await clashFacade
|
||||
.validateConfig(fileName)
|
||||
.getOrElse((_) => false)
|
||||
.run();
|
||||
if (!isValid) {
|
||||
await File(path).delete();
|
||||
return left(const ProfileFailure.invalidConfig());
|
||||
}
|
||||
final subInfoString =
|
||||
response.headers.map['subscription-userinfo']?.single;
|
||||
final subInfo = subInfoString != null
|
||||
? SubscriptionInfo.fromResponseHeader(subInfoString)
|
||||
: null;
|
||||
return right(subInfo);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
2
lib/data/repository/repository.dart
Normal file
2
lib/data/repository/repository.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'clash_facade_impl.dart';
|
||||
export 'profiles_repository_impl.dart';
|
||||
7
lib/domain/clash/clash.dart
Normal file
7
lib/domain/clash/clash.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
export 'clash_config.dart';
|
||||
export 'clash_enums.dart';
|
||||
export 'clash_facade.dart';
|
||||
export 'clash_failures.dart';
|
||||
export 'clash_log.dart';
|
||||
export 'clash_proxy.dart';
|
||||
export 'clash_traffic.dart';
|
||||
72
lib/domain/clash/clash_config.dart
Normal file
72
lib/domain/clash/clash_config.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash_enums.dart';
|
||||
|
||||
part 'clash_config.freezed.dart';
|
||||
part 'clash_config.g.dart';
|
||||
|
||||
@freezed
|
||||
class ClashConfig with _$ClashConfig {
|
||||
const ClashConfig._();
|
||||
|
||||
@JsonSerializable(includeIfNull: false, fieldRename: FieldRename.kebab)
|
||||
const factory ClashConfig({
|
||||
@JsonKey(name: 'port') int? httpPort,
|
||||
int? socksPort,
|
||||
int? redirPort,
|
||||
int? tproxyPort,
|
||||
int? mixedPort,
|
||||
List<String>? authentication,
|
||||
bool? allowLan,
|
||||
String? bindAddress,
|
||||
TunnelMode? mode,
|
||||
LogLevel? logLevel,
|
||||
bool? ipv6,
|
||||
}) = _ClashConfig;
|
||||
|
||||
static const initial = ClashConfig(
|
||||
httpPort: 12346,
|
||||
socksPort: 12347,
|
||||
mixedPort: 12348,
|
||||
);
|
||||
|
||||
ClashConfig patch(ClashConfigPatch patch) {
|
||||
return copyWith(
|
||||
httpPort: (patch.httpPort ?? optionOf(httpPort)).toNullable(),
|
||||
socksPort: (patch.socksPort ?? optionOf(socksPort)).toNullable(),
|
||||
redirPort: (patch.redirPort ?? optionOf(redirPort)).toNullable(),
|
||||
tproxyPort: (patch.tproxyPort ?? optionOf(tproxyPort)).toNullable(),
|
||||
mixedPort: (patch.mixedPort ?? optionOf(mixedPort)).toNullable(),
|
||||
authentication:
|
||||
(patch.authentication ?? optionOf(authentication)).toNullable(),
|
||||
allowLan: (patch.allowLan ?? optionOf(allowLan)).toNullable(),
|
||||
bindAddress: (patch.bindAddress ?? optionOf(bindAddress)).toNullable(),
|
||||
mode: (patch.mode ?? optionOf(mode)).toNullable(),
|
||||
logLevel: (patch.logLevel ?? optionOf(logLevel)).toNullable(),
|
||||
ipv6: (patch.ipv6 ?? optionOf(ipv6)).toNullable(),
|
||||
);
|
||||
}
|
||||
|
||||
factory ClashConfig.fromJson(Map<String, dynamic> json) =>
|
||||
_$ClashConfigFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ClashConfigPatch with _$ClashConfigPatch {
|
||||
const ClashConfigPatch._();
|
||||
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
const factory ClashConfigPatch({
|
||||
Option<int>? httpPort,
|
||||
Option<int>? socksPort,
|
||||
Option<int>? redirPort,
|
||||
Option<int>? tproxyPort,
|
||||
Option<int>? mixedPort,
|
||||
Option<List<String>>? authentication,
|
||||
Option<bool>? allowLan,
|
||||
Option<String>? bindAddress,
|
||||
Option<TunnelMode>? mode,
|
||||
Option<LogLevel>? logLevel,
|
||||
Option<bool>? ipv6,
|
||||
}) = _ClashConfigPatch;
|
||||
}
|
||||
61
lib/domain/clash/clash_enums.dart
Normal file
61
lib/domain/clash/clash_enums.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum TunnelMode {
|
||||
rule,
|
||||
global,
|
||||
direct;
|
||||
}
|
||||
|
||||
enum LogLevel {
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
debug,
|
||||
silent;
|
||||
|
||||
Color get color => switch (this) {
|
||||
info => Colors.lightGreen,
|
||||
warning => Colors.orangeAccent,
|
||||
error => Colors.redAccent,
|
||||
debug => Colors.lightBlue,
|
||||
_ => Colors.white,
|
||||
};
|
||||
}
|
||||
|
||||
enum ProxyType {
|
||||
direct("Direct"),
|
||||
reject("Reject"),
|
||||
compatible("Compatible"),
|
||||
pass("Pass"),
|
||||
shadowSocks("ShadowSocks"),
|
||||
shadowSocksR("ShadowSocksR"),
|
||||
snell("Snell"),
|
||||
socks5("Socks5"),
|
||||
http("Http"),
|
||||
vmess("Vmess"),
|
||||
vless("Vless"),
|
||||
trojan("Trojan"),
|
||||
hysteria("Hysteria"),
|
||||
wireGuard("WireGuard"),
|
||||
tuic("Tuic"),
|
||||
relay("Relay"),
|
||||
selector("Selector"),
|
||||
fallback("Fallback"),
|
||||
urlTest("URLTest", "urltest"),
|
||||
loadBalance("LoadBalance"),
|
||||
unknown("Unknown");
|
||||
|
||||
const ProxyType(this.label, [this._key]);
|
||||
|
||||
final String? _key;
|
||||
final String label;
|
||||
|
||||
String get key => _key ?? name;
|
||||
|
||||
static List<ProxyType> groupValues = [
|
||||
selector,
|
||||
fallback,
|
||||
urlTest,
|
||||
loadBalance,
|
||||
];
|
||||
}
|
||||
32
lib/domain/clash/clash_facade.dart
Normal file
32
lib/domain/clash/clash_facade.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/constants.dart';
|
||||
|
||||
abstract class ClashFacade {
|
||||
TaskEither<ClashFailure, ClashConfig> getConfigs();
|
||||
|
||||
TaskEither<ClashFailure, bool> validateConfig(String configFileName);
|
||||
|
||||
/// change active configuration file by [configFileName]
|
||||
TaskEither<ClashFailure, Unit> changeConfigs(String configFileName);
|
||||
|
||||
TaskEither<ClashFailure, Unit> patchOverrides(ClashConfig overrides);
|
||||
|
||||
TaskEither<ClashFailure, List<ClashProxy>> getProxies();
|
||||
|
||||
TaskEither<ClashFailure, Unit> changeProxy(
|
||||
String selectorName,
|
||||
String proxyName,
|
||||
);
|
||||
|
||||
TaskEither<ClashFailure, int> testDelay(
|
||||
String proxyName, {
|
||||
String testUrl = Constants.delayTestUrl,
|
||||
});
|
||||
|
||||
TaskEither<ClashFailure, ClashTraffic> getTraffic();
|
||||
|
||||
Stream<Either<ClashFailure, ClashLog>> watchLogs();
|
||||
}
|
||||
27
lib/domain/clash/clash_failures.dart
Normal file
27
lib/domain/clash/clash_failures.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
|
||||
part 'clash_failures.freezed.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
@freezed
|
||||
sealed class ClashFailure with _$ClashFailure, Failure {
|
||||
const ClashFailure._();
|
||||
|
||||
const factory ClashFailure.unexpected(
|
||||
Object error,
|
||||
StackTrace stackTrace,
|
||||
) = ClashUnexpectedFailure;
|
||||
|
||||
const factory ClashFailure.core([String? error]) = ClashCoreFailure;
|
||||
|
||||
@override
|
||||
String present(TranslationsEn t) {
|
||||
return switch (this) {
|
||||
ClashUnexpectedFailure() => t.failure.clash.unexpected,
|
||||
ClashCoreFailure(:final error) =>
|
||||
t.failure.clash.core(reason: error ?? ""),
|
||||
};
|
||||
}
|
||||
}
|
||||
22
lib/domain/clash/clash_log.dart
Normal file
22
lib/domain/clash/clash_log.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash_enums.dart';
|
||||
|
||||
part 'clash_log.freezed.dart';
|
||||
part 'clash_log.g.dart';
|
||||
|
||||
@freezed
|
||||
class ClashLog with _$ClashLog {
|
||||
const ClashLog._();
|
||||
|
||||
const factory ClashLog({
|
||||
@JsonKey(name: 'type') required LogLevel level,
|
||||
@JsonKey(name: 'payload') required String message,
|
||||
@JsonKey(defaultValue: DateTime.now) required DateTime time,
|
||||
}) = _ClashLog;
|
||||
|
||||
String get timeStamp =>
|
||||
"${time.month}-${time.day} ${time.hour}:${time.minute}:${time.second}";
|
||||
|
||||
factory ClashLog.fromJson(Map<String, dynamic> json) =>
|
||||
_$ClashLogFromJson(json);
|
||||
}
|
||||
59
lib/domain/clash/clash_proxy.dart
Normal file
59
lib/domain/clash/clash_proxy.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash_enums.dart';
|
||||
|
||||
part 'clash_proxy.freezed.dart';
|
||||
part 'clash_proxy.g.dart';
|
||||
|
||||
// TODO: test and improve
|
||||
@Freezed(fromJson: true)
|
||||
class ClashProxy with _$ClashProxy {
|
||||
const ClashProxy._();
|
||||
|
||||
const factory ClashProxy.group({
|
||||
required String name,
|
||||
@JsonKey(fromJson: _typeFromJson) required ProxyType type,
|
||||
required List<String> all,
|
||||
required String now,
|
||||
List<ClashHistory>? history,
|
||||
@JsonKey(includeFromJson: false, includeToJson: false) int? delay,
|
||||
}) = ClashProxyGroup;
|
||||
|
||||
const factory ClashProxy.item({
|
||||
required String name,
|
||||
@JsonKey(fromJson: _typeFromJson) required ProxyType type,
|
||||
List<ClashHistory>? history,
|
||||
@JsonKey(includeFromJson: false, includeToJson: false) int? delay,
|
||||
}) = ClashProxyItem;
|
||||
|
||||
factory ClashProxy.fromJson(Map<String, dynamic> json) {
|
||||
final isGroup = json.containsKey('all') ||
|
||||
json.containsKey('now') ||
|
||||
ProxyType.groupValues.any(
|
||||
(e) => e.label == json.getOrElse('type', () => null),
|
||||
);
|
||||
if (isGroup) {
|
||||
return ClashProxyGroup.fromJson(json);
|
||||
} else {
|
||||
return ClashProxyItem.fromJson(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProxyType _typeFromJson(dynamic type) =>
|
||||
ProxyType.values
|
||||
.firstOrNullWhere((e) => e.key == (type as String?)?.toLowerCase()) ??
|
||||
ProxyType.unknown;
|
||||
|
||||
@freezed
|
||||
class ClashHistory with _$ClashHistory {
|
||||
const ClashHistory._();
|
||||
|
||||
const factory ClashHistory({
|
||||
required String time,
|
||||
required int delay,
|
||||
}) = _ClashHistory;
|
||||
|
||||
factory ClashHistory.fromJson(Map<String, dynamic> json) =>
|
||||
_$ClashHistoryFromJson(json);
|
||||
}
|
||||
17
lib/domain/clash/clash_traffic.dart
Normal file
17
lib/domain/clash/clash_traffic.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'clash_traffic.freezed.dart';
|
||||
part 'clash_traffic.g.dart';
|
||||
|
||||
@freezed
|
||||
class ClashTraffic with _$ClashTraffic {
|
||||
const ClashTraffic._();
|
||||
|
||||
const factory ClashTraffic({
|
||||
@JsonKey(name: 'up') required int upload,
|
||||
@JsonKey(name: 'down') required int download,
|
||||
}) = _ClashTraffic;
|
||||
|
||||
factory ClashTraffic.fromJson(Map<String, dynamic> json) =>
|
||||
_$ClashTrafficFromJson(json);
|
||||
}
|
||||
43
lib/domain/connectivity/connection_status.dart
Normal file
43
lib/domain/connectivity/connection_status.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity_failure.dart';
|
||||
|
||||
part 'connection_status.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ConnectionStatus with _$ConnectionStatus {
|
||||
const ConnectionStatus._();
|
||||
|
||||
const factory ConnectionStatus.disconnected([
|
||||
ConnectivityFailure? connectFailure,
|
||||
]) = Disconnected;
|
||||
const factory ConnectionStatus.connecting() = Connecting;
|
||||
const factory ConnectionStatus.connected([
|
||||
ConnectivityFailure? disconnectFailure,
|
||||
]) = Connected;
|
||||
const factory ConnectionStatus.disconnecting() = Disconnecting;
|
||||
|
||||
factory ConnectionStatus.fromBool(bool connected) {
|
||||
return connected
|
||||
? const ConnectionStatus.connected()
|
||||
: const Disconnected();
|
||||
}
|
||||
|
||||
bool get isConnected => switch (this) {
|
||||
Connected() => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
bool get isSwitching => switch (this) {
|
||||
Connecting() => true,
|
||||
Disconnecting() => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
String present(TranslationsEn t) => switch (this) {
|
||||
Disconnected() => t.home.connection.tapToConnect,
|
||||
Connecting() => t.home.connection.connecting,
|
||||
Connected() => t.home.connection.connected,
|
||||
Disconnecting() => t.home.connection.disconnecting,
|
||||
};
|
||||
}
|
||||
4
lib/domain/connectivity/connectivity.dart
Normal file
4
lib/domain/connectivity/connectivity.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'connection_status.dart';
|
||||
export 'connectivity_failure.dart';
|
||||
export 'network_prefs.dart';
|
||||
export 'traffic.dart';
|
||||
21
lib/domain/connectivity/connectivity_failure.dart
Normal file
21
lib/domain/connectivity/connectivity_failure.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
|
||||
part 'connectivity_failure.freezed.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
@freezed
|
||||
sealed class ConnectivityFailure with _$ConnectivityFailure, Failure {
|
||||
const ConnectivityFailure._();
|
||||
|
||||
const factory ConnectivityFailure.unexpected([
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
]) = ConnectivityUnexpectedFailure;
|
||||
|
||||
@override
|
||||
String present(TranslationsEn t) {
|
||||
return t.failure.connectivity.unexpected;
|
||||
}
|
||||
}
|
||||
17
lib/domain/connectivity/network_prefs.dart
Normal file
17
lib/domain/connectivity/network_prefs.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'network_prefs.freezed.dart';
|
||||
part 'network_prefs.g.dart';
|
||||
|
||||
@freezed
|
||||
class NetworkPrefs with _$NetworkPrefs {
|
||||
const NetworkPrefs._();
|
||||
|
||||
const factory NetworkPrefs({
|
||||
@Default(true) bool systemProxy,
|
||||
@Default(true) bool bypassPrivateNetworks,
|
||||
}) = _NetworkPrefs;
|
||||
|
||||
factory NetworkPrefs.fromJson(Map<String, dynamic> json) =>
|
||||
_$NetworkPrefsFromJson(json);
|
||||
}
|
||||
13
lib/domain/connectivity/traffic.dart
Normal file
13
lib/domain/connectivity/traffic.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'traffic.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class Traffic with _$Traffic {
|
||||
const Traffic._();
|
||||
|
||||
const factory Traffic({
|
||||
required int upload,
|
||||
required int download,
|
||||
}) = _Traffic;
|
||||
}
|
||||
7
lib/domain/constants.dart
Normal file
7
lib/domain/constants.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
abstract class Constants {
|
||||
static const localHost = '127.0.0.1';
|
||||
static const clashFolderName = "clash";
|
||||
static const delayTestUrl = "https://www.google.com";
|
||||
static const configFileName = "config";
|
||||
static const countryMMDBFileName = "Country";
|
||||
}
|
||||
13
lib/domain/failures.dart
Normal file
13
lib/domain/failures.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
mixin Failure {
|
||||
String present(TranslationsEn t);
|
||||
}
|
||||
|
||||
extension ErrorPresenter on TranslationsEn {
|
||||
String presentError(Object error) {
|
||||
if (error case Failure()) return error.present(this);
|
||||
return failure.unexpected;
|
||||
}
|
||||
}
|
||||
25
lib/domain/profiles/profile.dart
Normal file
25
lib/domain/profiles/profile.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
|
||||
part 'profile.freezed.dart';
|
||||
part 'profile.g.dart';
|
||||
|
||||
@freezed
|
||||
class Profile with _$Profile {
|
||||
const Profile._();
|
||||
|
||||
const factory Profile({
|
||||
required String id,
|
||||
required bool active,
|
||||
required String name,
|
||||
required String url,
|
||||
SubscriptionInfo? subInfo,
|
||||
Duration? updateInterval,
|
||||
required DateTime lastUpdate,
|
||||
}) = _Profile;
|
||||
|
||||
bool get hasSubscriptionInfo => subInfo?.isValid ?? false;
|
||||
|
||||
factory Profile.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfileFromJson(json);
|
||||
}
|
||||
4
lib/domain/profiles/profiles.dart
Normal file
4
lib/domain/profiles/profiles.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'profile.dart';
|
||||
export 'profiles_failure.dart';
|
||||
export 'profiles_repository.dart';
|
||||
export 'subscription_info.dart';
|
||||
28
lib/domain/profiles/profiles_failure.dart
Normal file
28
lib/domain/profiles/profiles_failure.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
|
||||
part 'profiles_failure.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ProfileFailure with _$ProfileFailure, Failure {
|
||||
const ProfileFailure._();
|
||||
|
||||
const factory ProfileFailure.unexpected([
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
]) = ProfileUnexpectedFailure;
|
||||
|
||||
const factory ProfileFailure.notFound() = ProfileNotFoundFailure;
|
||||
|
||||
const factory ProfileFailure.invalidConfig() = ProfileInvalidConfigFailure;
|
||||
|
||||
@override
|
||||
String present(TranslationsEn t) {
|
||||
return switch (this) {
|
||||
ProfileUnexpectedFailure() => t.failure.profiles.unexpected,
|
||||
ProfileNotFoundFailure() => t.failure.profiles.notFound,
|
||||
ProfileInvalidConfigFailure() => t.failure.profiles.invalidConfig,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
lib/domain/profiles/profiles_repository.dart
Normal file
20
lib/domain/profiles/profiles_repository.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
|
||||
abstract class ProfilesRepository {
|
||||
TaskEither<ProfileFailure, Profile?> get(String id);
|
||||
|
||||
Stream<Either<ProfileFailure, Profile?>> watchActiveProfile();
|
||||
|
||||
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile();
|
||||
|
||||
Stream<Either<ProfileFailure, List<Profile>>> watchAll();
|
||||
|
||||
TaskEither<ProfileFailure, Unit> add(Profile baseProfile);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> update(Profile baseProfile);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> setAsActive(String id);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> delete(String id);
|
||||
}
|
||||
44
lib/domain/profiles/subscription_info.dart
Normal file
44
lib/domain/profiles/subscription_info.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'subscription_info.freezed.dart';
|
||||
part 'subscription_info.g.dart';
|
||||
|
||||
// TODO: test and improve
|
||||
@freezed
|
||||
class SubscriptionInfo with _$SubscriptionInfo {
|
||||
const SubscriptionInfo._();
|
||||
|
||||
const factory SubscriptionInfo({
|
||||
int? upload,
|
||||
int? download,
|
||||
int? total,
|
||||
@JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) DateTime? expire,
|
||||
}) = _SubscriptionInfo;
|
||||
|
||||
bool get isValid =>
|
||||
total != null && download != null && upload != null && expire != null;
|
||||
|
||||
bool get isExpired => expire! <= DateTime.now();
|
||||
|
||||
int get consumption => upload! + download!;
|
||||
|
||||
double get ratio => consumption / total!;
|
||||
|
||||
Duration get remaining => expire!.difference(DateTime.now());
|
||||
|
||||
factory SubscriptionInfo.fromResponseHeader(String header) {
|
||||
final values = header.split(';');
|
||||
final map = {
|
||||
for (final v in values)
|
||||
v.split('=').first: int.tryParse(v.split('=').second)
|
||||
};
|
||||
return SubscriptionInfo.fromJson(map);
|
||||
}
|
||||
|
||||
factory SubscriptionInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$SubscriptionInfoFromJson(json);
|
||||
}
|
||||
|
||||
DateTime? _dateTimeFromSecondsSinceEpoch(dynamic expire) =>
|
||||
DateTime.fromMillisecondsSinceEpoch((expire as int) * 1000);
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'active_profile_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ActiveProfile extends _$ActiveProfile with AppLogger {
|
||||
@override
|
||||
Stream<Profile?> build() {
|
||||
return ref
|
||||
.watch(profilesRepositoryProvider)
|
||||
.watchActiveProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
|
||||
Future<Unit?> updateProfile() async {
|
||||
if (state case AsyncData(value: final profile?)) {
|
||||
loggy.debug("updating active profile");
|
||||
return ref
|
||||
.read(profilesRepositoryProvider)
|
||||
.update(profile)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'has_any_profile_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<bool> hasAnyProfile(
|
||||
HasAnyProfileRef ref,
|
||||
) {
|
||||
return ref
|
||||
.watch(profilesRepositoryProvider)
|
||||
.watchHasAnyProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
202
lib/features/common/add_profile_modal.dart
Normal file
202
lib/features/common/add_profile_modal.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/features/common/qr_code_scanner_screen.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class AddProfileModal extends HookConsumerWidget {
|
||||
const AddProfileModal({
|
||||
super.key,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController? scrollController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
const buttonsPadding = 24.0;
|
||||
const buttonsGap = 16.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// temporary solution, aspect ratio widget relies on height and in a row there no height!
|
||||
final buttonWidth = constraints.maxWidth / 2 -
|
||||
(buttonsPadding + (buttonsGap / 2));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: buttonsPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
_Button(
|
||||
label: t.profile.add.fromClipboard.sentenceCase,
|
||||
icon: Icons.content_paste,
|
||||
size: buttonWidth,
|
||||
onTap: () async {
|
||||
final captureResult =
|
||||
await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final link =
|
||||
LinkParser.simple(captureResult?.text ?? '');
|
||||
if (link != null && context.mounted) {
|
||||
context.pop();
|
||||
await NewProfileRoute(url: link.url, name: link.name)
|
||||
.push(context);
|
||||
} else {
|
||||
CustomToast.error(
|
||||
t.profile.add.invalidUrlMsg.sentenceCase,
|
||||
).show(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Gap(buttonsGap),
|
||||
if (!PlatformUtils.isDesktop)
|
||||
_Button(
|
||||
label: t.profile.add.scanQr,
|
||||
icon: Icons.qr_code_scanner,
|
||||
size: buttonWidth,
|
||||
onTap: () async {
|
||||
final captureResult =
|
||||
await const QRCodeScannerScreen().open(context);
|
||||
if (captureResult == null) return;
|
||||
final link = LinkParser.simple(captureResult);
|
||||
if (link != null && context.mounted) {
|
||||
context.pop();
|
||||
await NewProfileRoute(
|
||||
url: link.url,
|
||||
name: link.name,
|
||||
).push(context);
|
||||
} else {
|
||||
CustomToast.error(
|
||||
t.profile.add.invalidUrlMsg.sentenceCase,
|
||||
).show(context);
|
||||
}
|
||||
},
|
||||
)
|
||||
else
|
||||
_Button(
|
||||
label: t.profile.add.manually.sentenceCase,
|
||||
icon: Icons.add,
|
||||
size: buttonWidth,
|
||||
onTap: () async {
|
||||
context.pop();
|
||||
await const NewProfileRoute().push(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!PlatformUtils.isDesktop)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: buttonsPadding,
|
||||
vertical: 16,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
shadowColor: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
context.pop();
|
||||
await const NewProfileRoute().push(context);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
t.profile.add.manually.sentenceCase,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Button extends StatelessWidget {
|
||||
const _Button({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.size,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final double size;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
shadowColor: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: size / 3,
|
||||
color: color,
|
||||
),
|
||||
const Gap(16),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/features/common/clash/clash_controller.dart
Normal file
54
lib/features/common/clash/clash_controller.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/constants.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'clash_controller.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ClashController extends _$ClashController with AppLogger {
|
||||
Profile? _oldProfile;
|
||||
|
||||
@override
|
||||
Future<void> build() async {
|
||||
final clash = ref.watch(clashFacadeProvider);
|
||||
|
||||
final overridesListener = ref.listen(
|
||||
prefsControllerProvider.select((value) => value.clash),
|
||||
(_, overrides) async {
|
||||
loggy.debug("new clash overrides received, patching...");
|
||||
await clash.patchOverrides(overrides).getOrElse((l) => throw l).run();
|
||||
},
|
||||
);
|
||||
final overrides = overridesListener.read();
|
||||
|
||||
final activeProfile = await ref.watch(activeProfileProvider.future);
|
||||
final oldProfile = _oldProfile;
|
||||
_oldProfile = activeProfile;
|
||||
if (activeProfile != null) {
|
||||
if (oldProfile == null ||
|
||||
oldProfile.id != activeProfile.id ||
|
||||
oldProfile.lastUpdate != activeProfile.lastUpdate) {
|
||||
loggy.debug("profile changed or updated, updating clash core");
|
||||
await clash
|
||||
.changeConfigs(activeProfile.id)
|
||||
.call(clash.patchOverrides(overrides))
|
||||
.getOrElse((error) {
|
||||
loggy.warning("failed to change or patch configs, $error");
|
||||
throw error;
|
||||
}).run();
|
||||
}
|
||||
} else {
|
||||
if (oldProfile != null) {
|
||||
loggy.debug("active profile removed, resetting clash");
|
||||
await clash
|
||||
.changeConfigs(Constants.configFileName)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
lib/features/common/clash/clash_mode.dart
Normal file
23
lib/features/common/clash/clash_mode.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/common/clash/clash_controller.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'clash_mode.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ClashMode extends _$ClashMode with AppLogger {
|
||||
@override
|
||||
Future<TunnelMode?> build() async {
|
||||
final clash = ref.watch(clashFacadeProvider);
|
||||
await ref.watch(clashControllerProvider.future);
|
||||
ref.watch(prefsControllerProvider.select((value) => value.clash.mode));
|
||||
return clash
|
||||
.getConfigs()
|
||||
.map((r) => r.mode)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
4
lib/features/common/common.dart
Normal file
4
lib/features/common/common.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'add_profile_modal.dart';
|
||||
export 'custom_app_bar.dart';
|
||||
export 'qr_code_scanner_screen.dart';
|
||||
export 'remaining_traffic_indicator.dart';
|
||||
31
lib/features/common/confirmation_dialogs.dart
Normal file
31
lib/features/common/confirmation_dialogs.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
Future<bool> showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
IconData? icon,
|
||||
}) async {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final localizations = MaterialLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(true),
|
||||
child: Text(localizations.okButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(false),
|
||||
child: Text(localizations.cancelButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((value) => value ?? false);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/services/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'connectivity_controller.g.dart';
|
||||
|
||||
// TODO: test and improve
|
||||
// TODO: abort connection on clash error
|
||||
@Riverpod(keepAlive: true)
|
||||
class ConnectivityController extends _$ConnectivityController with AppLogger {
|
||||
@override
|
||||
ConnectionStatus build() {
|
||||
state = const Disconnected();
|
||||
final connection = _connectivity
|
||||
.watchConnectionStatus()
|
||||
.map(ConnectionStatus.fromBool)
|
||||
.listen((event) => state = event);
|
||||
|
||||
// currently changes wont take effect while connected
|
||||
ref.listen(
|
||||
prefsControllerProvider.select((value) => value.network),
|
||||
(_, next) => _networkPrefs = next,
|
||||
fireImmediately: true,
|
||||
);
|
||||
ref.listen(
|
||||
prefsControllerProvider
|
||||
.select((value) => (value.clash.httpPort!, value.clash.socksPort!)),
|
||||
(_, next) => _ports = (http: next.$1, socks: next.$2),
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
ref.onDispose(connection.cancel);
|
||||
return state;
|
||||
}
|
||||
|
||||
ConnectivityService get _connectivity =>
|
||||
ref.watch(connectivityServiceProvider);
|
||||
|
||||
late ({int http, int socks}) _ports;
|
||||
// ignore: unused_field
|
||||
late NetworkPrefs _networkPrefs;
|
||||
|
||||
Future<void> toggleConnection() async {
|
||||
switch (state) {
|
||||
case Disconnected():
|
||||
if (!await _connectivity.grantVpnPermission()) {
|
||||
state = const Disconnected(ConnectivityFailure.unexpected());
|
||||
return;
|
||||
}
|
||||
await _connectivity.connect(
|
||||
httpPort: _ports.http,
|
||||
socksPort: _ports.socks,
|
||||
);
|
||||
case Connected():
|
||||
await _connectivity.disconnect();
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
24
lib/features/common/custom_app_bar.dart
Normal file
24
lib/features/common/custom_app_bar.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class RootScaffold {
|
||||
static final stateKey = GlobalKey<ScaffoldState>();
|
||||
}
|
||||
|
||||
class NestedTabAppBar extends SliverAppBar {
|
||||
NestedTabAppBar({
|
||||
super.key,
|
||||
super.title,
|
||||
super.actions,
|
||||
super.pinned = true,
|
||||
super.forceElevated,
|
||||
super.bottom,
|
||||
}) : super(
|
||||
leading: RootScaffold.stateKey.currentState?.hasDrawer ?? false
|
||||
? DrawerButton(
|
||||
onPressed: () {
|
||||
RootScaffold.stateKey.currentState?.openDrawer();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
82
lib/features/common/qr_code_scanner_screen.dart
Normal file
82
lib/features/common/qr_code_scanner_screen.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
class QRCodeScannerScreen extends HookConsumerWidget with PresLogger {
|
||||
const QRCodeScannerScreen({super.key});
|
||||
|
||||
Future<String?> open(BuildContext context) async {
|
||||
return Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) => const QRCodeScannerScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useMemoized(
|
||||
() => MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
formats: [BarcodeFormat.qrCode],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => controller.dispose, []);
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
iconTheme: Theme.of(context).iconTheme.copyWith(
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder(
|
||||
valueListenable: controller.torchState,
|
||||
builder: (context, state, child) {
|
||||
switch (state) {
|
||||
case TorchState.off:
|
||||
return const Icon(Icons.flash_off, color: Colors.grey);
|
||||
case TorchState.on:
|
||||
return const Icon(Icons.flash_on, color: Colors.yellow);
|
||||
}
|
||||
},
|
||||
),
|
||||
onPressed: () => controller.toggleTorch(),
|
||||
),
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder(
|
||||
valueListenable: controller.cameraFacingState,
|
||||
builder: (context, state, child) {
|
||||
switch (state) {
|
||||
case CameraFacing.front:
|
||||
return const Icon(Icons.camera_front);
|
||||
case CameraFacing.back:
|
||||
return const Icon(Icons.camera_rear);
|
||||
}
|
||||
},
|
||||
),
|
||||
onPressed: () => controller.switchCamera(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: MobileScanner(
|
||||
controller: controller,
|
||||
onDetect: (capture) {
|
||||
final data = capture.barcodes.first;
|
||||
if (context.mounted && data.type == BarcodeType.url) {
|
||||
loggy.debug('captured raw: [${data.rawValue}]');
|
||||
loggy.debug('captured url: [${data.url?.url}]');
|
||||
Navigator.of(context, rootNavigator: true).pop(data.url?.url);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/features/common/remaining_traffic_indicator.dart
Normal file
34
lib/features/common/remaining_traffic_indicator.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:percent_indicator/percent_indicator.dart';
|
||||
|
||||
// TODO: change colors
|
||||
class RemainingTrafficIndicator extends StatelessWidget {
|
||||
const RemainingTrafficIndicator(this.ratio, {super.key});
|
||||
|
||||
final double ratio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final startColor = ratio < 0.25
|
||||
? const Color.fromRGBO(93, 205, 251, 1.0)
|
||||
: ratio < 0.65
|
||||
? const Color.fromRGBO(205, 199, 64, 1.0)
|
||||
: const Color.fromRGBO(241, 82, 81, 1.0);
|
||||
final endColor = ratio < 0.25
|
||||
? const Color.fromRGBO(49, 146, 248, 1.0)
|
||||
: ratio < 0.65
|
||||
? const Color.fromRGBO(98, 115, 32, 1.0)
|
||||
: const Color.fromRGBO(139, 30, 36, 1.0);
|
||||
|
||||
return LinearPercentIndicator(
|
||||
percent: ratio,
|
||||
animation: true,
|
||||
padding: EdgeInsets.zero,
|
||||
lineHeight: 6,
|
||||
barRadius: const Radius.circular(16),
|
||||
linearGradient: LinearGradient(
|
||||
colors: [startColor, endColor],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/features/common/traffic/traffic_chart.dart
Normal file
94
lib/features/common/traffic/traffic_chart.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/features/common/traffic/traffic_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
// TODO: test implementation, rewrite
|
||||
class TrafficChart extends HookConsumerWidget {
|
||||
const TrafficChart({
|
||||
super.key,
|
||||
this.chartSteps = 20,
|
||||
});
|
||||
|
||||
final int chartSteps;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncTraffics = ref.watch(trafficNotifierProvider);
|
||||
|
||||
switch (asyncTraffics) {
|
||||
case AsyncData(value: final traffics):
|
||||
final latest =
|
||||
traffics.lastOrNull ?? const Traffic(upload: 0, download: 0);
|
||||
final latestUploadData = formatByteSpeed(latest.upload);
|
||||
final latestDownloadData = formatByteSpeed(latest.download);
|
||||
|
||||
final uploadChartSpots = traffics.takeLast(chartSteps).mapIndexed(
|
||||
(index, p) => FlSpot(index.toDouble(), p.upload.toDouble()),
|
||||
);
|
||||
final downloadChartSpots = traffics.takeLast(chartSteps).mapIndexed(
|
||||
(index, p) => FlSpot(index.toDouble(), p.download.toDouble()),
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 68,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minY: 0,
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
gridData: const FlGridData(show: false),
|
||||
lineTouchData: const LineTouchData(enabled: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
isCurved: true,
|
||||
preventCurveOverShooting: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
spots: uploadChartSpots.toList(),
|
||||
),
|
||||
LineChartBarData(
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
isCurved: true,
|
||||
preventCurveOverShooting: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
spots: downloadChartSpots.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
const Text("↑"),
|
||||
Text(latestUploadData.size),
|
||||
Text(latestUploadData.unit),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
const Text("↓"),
|
||||
Text(latestDownloadData.size),
|
||||
Text(latestDownloadData.unit),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
],
|
||||
);
|
||||
// TODO: handle loading and error
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
}
|
||||
40
lib/features/common/traffic/traffic_notifier.dart
Normal file
40
lib/features/common/traffic/traffic_notifier.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'traffic_notifier.g.dart';
|
||||
|
||||
// TODO: improve
|
||||
@riverpod
|
||||
class TrafficNotifier extends _$TrafficNotifier with AppLogger {
|
||||
int get _steps => 100;
|
||||
|
||||
@override
|
||||
Stream<List<Traffic>> build() {
|
||||
return Stream.periodic(const Duration(seconds: 1)).asyncMap(
|
||||
(_) async {
|
||||
return ref.read(clashFacadeProvider).getTraffic().match(
|
||||
(f) {
|
||||
loggy.warning('failed to watch clash traffic: $f');
|
||||
return const ClashTraffic(upload: 0, download: 0);
|
||||
},
|
||||
(traffic) => traffic,
|
||||
).run();
|
||||
},
|
||||
).map(
|
||||
(event) => switch (state) {
|
||||
AsyncData(:final value) => [
|
||||
...value.takeLast(_steps - 1),
|
||||
Traffic(upload: event.upload, download: event.download),
|
||||
],
|
||||
_ => List.generate(
|
||||
_steps,
|
||||
(index) => const Traffic(upload: 0, download: 0),
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/features/home/view/home_page.dart
Normal file
84
lib/features/home/view/home_page.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/active_profile/has_any_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/clash/clash_controller.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/home/widgets/widgets.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final hasAnyProfile = ref.watch(hasAnyProfileProvider);
|
||||
final activeProfile = ref.watch(activeProfileProvider);
|
||||
|
||||
ref.listen(
|
||||
clashControllerProvider,
|
||||
(_, next) {
|
||||
if (next case AsyncError(:final error)) {
|
||||
CustomToast.error(
|
||||
t.presentError(error),
|
||||
duration: const Duration(seconds: 10),
|
||||
).show(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.general.appTitle.titleCase),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => const AddProfileRoute().push(context),
|
||||
icon: const Icon(Icons.add_circle),
|
||||
),
|
||||
],
|
||||
),
|
||||
switch (activeProfile) {
|
||||
AsyncData(value: final profile?) => MultiSliver(
|
||||
children: [
|
||||
ActiveProfileCard(profile),
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 86,
|
||||
),
|
||||
child: ConnectionButton(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AsyncData() => switch (hasAnyProfile) {
|
||||
AsyncData(value: true) =>
|
||||
const EmptyActiveProfileHomeBody(),
|
||||
_ => const EmptyProfilesHomeBody(),
|
||||
},
|
||||
AsyncError(:final error) =>
|
||||
SliverErrorBodyPlaceholder(t.presentError(error)),
|
||||
_ => const SliverToBoxAdapter(),
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/home/view/view.dart
Normal file
1
lib/features/home/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'home_page.dart';
|
||||
171
lib/features/home/widgets/active_profile_card.dart
Normal file
171
lib/features/home/widgets/active_profile_card.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
class ActiveProfileCard extends HookConsumerWidget {
|
||||
const ActiveProfileCard(this.profile, {super.key});
|
||||
|
||||
final Profile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
await const ProfilesRoute().push(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
profile.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
const AddProfileRoute().push(context);
|
||||
},
|
||||
label: Text(t.profile.add.buttonText.titleCase),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (profile.hasSubscriptionInfo) ...[
|
||||
const Divider(thickness: 0.5),
|
||||
SubscriptionInfoTile(profile.subInfo!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionInfoTile extends HookConsumerWidget {
|
||||
const SubscriptionInfoTile(this.subInfo, {super.key});
|
||||
|
||||
final SubscriptionInfo subInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!subInfo.isValid) return const SizedBox.shrink();
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
final updateProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
initialOnSuccess: () =>
|
||||
CustomToast.success(t.profile.update.successMsg).show(context),
|
||||
);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
formatTrafficByteSize(
|
||||
subInfo.consumption,
|
||||
subInfo.total!,
|
||||
),
|
||||
style: themeData.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.profile.subscription.traffic,
|
||||
style: themeData.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
RemainingTrafficIndicator(subInfo.ratio),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
if (updateProfileMutation.state.isInProgress) return;
|
||||
updateProfileMutation.setFuture(
|
||||
ref.read(activeProfileProvider.notifier).updateProfile(),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 44),
|
||||
),
|
||||
const Gap(8),
|
||||
if (subInfo.isExpired)
|
||||
Text(
|
||||
t.profile.subscription.expired,
|
||||
style: themeData.textTheme.titleSmall
|
||||
?.copyWith(color: themeData.colorScheme.error),
|
||||
)
|
||||
else if (subInfo.ratio >= 1)
|
||||
Text(
|
||||
t.profile.subscription.noTraffic,
|
||||
style: themeData.textTheme.titleSmall
|
||||
?.copyWith(color: themeData.colorScheme.error),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
formatExpireDuration(subInfo.remaining),
|
||||
style: themeData.textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
t.profile.subscription.remaining,
|
||||
style: themeData.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/features/home/widgets/connection_button.dart
Normal file
70
lib/features/home/widgets/connection_button.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/theme/theme.dart';
|
||||
import 'package:hiddify/features/common/connectivity/connectivity_controller.dart';
|
||||
import 'package:hiddify/gen/assets.gen.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
class ConnectionButton extends HookConsumerWidget {
|
||||
const ConnectionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final connectionStatus = ref.watch(connectivityControllerProvider);
|
||||
|
||||
final Color connectionLogoColor = connectionStatus.isConnected
|
||||
? ConnectionButtonColor.connected
|
||||
: ConnectionButtonColor.disconnected;
|
||||
|
||||
final bool intractable = !connectionStatus.isSwitching;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 16,
|
||||
color: connectionLogoColor.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
width: 148,
|
||||
height: 148,
|
||||
child: Material(
|
||||
shape: const CircleBorder(),
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(connectivityControllerProvider.notifier)
|
||||
.toggleConnection();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(36),
|
||||
child: Assets.images.logo.svg(
|
||||
colorFilter: ColorFilter.mode(
|
||||
connectionLogoColor,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).animate(target: intractable ? 0 : 1).blurXY(end: 1),
|
||||
).animate(target: intractable ? 0 : 1).scaleXY(end: .88),
|
||||
const Gap(16),
|
||||
Text(
|
||||
connectionStatus.present(t),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
55
lib/features/home/widgets/empty_profiles_home_body.dart
Normal file
55
lib/features/home/widgets/empty_profiles_home_body.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class EmptyProfilesHomeBody extends HookConsumerWidget {
|
||||
const EmptyProfilesHomeBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t.home.emptyProfilesMsg.sentenceCase),
|
||||
const Gap(16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => const AddProfileRoute().push(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(t.profile.add.buttonText.titleCase),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyActiveProfileHomeBody extends HookConsumerWidget {
|
||||
const EmptyActiveProfileHomeBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t.home.noActiveProfileMsg.sentenceCase),
|
||||
const Gap(16),
|
||||
OutlinedButton(
|
||||
onPressed: () => const ProfilesRoute().push(context),
|
||||
child: Text(t.profile.overviewPageTitle.titleCase),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
3
lib/features/home/widgets/widgets.dart
Normal file
3
lib/features/home/widgets/widgets.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
export 'active_profile_card.dart';
|
||||
export 'connection_button.dart';
|
||||
export 'empty_profiles_home_body.dart';
|
||||
81
lib/features/logs/notifier/logs_notifier.dart
Normal file
81
lib/features/logs/notifier/logs_notifier.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/logs/notifier/logs_state.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'logs_notifier.g.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
@riverpod
|
||||
class LogsNotifier extends _$LogsNotifier with AppLogger {
|
||||
static const maxLength = 1000;
|
||||
|
||||
@override
|
||||
Stream<LogsState> build() {
|
||||
state = const AsyncData(LogsState());
|
||||
return ref.read(clashFacadeProvider).watchLogs().asyncMap(
|
||||
(event) async {
|
||||
_logs = [
|
||||
event.getOrElse((l) => throw l),
|
||||
..._logs.takeFirst(maxLength - 1),
|
||||
];
|
||||
return switch (state) {
|
||||
// ignore: unused_result
|
||||
AsyncData(:final value) => value.copyWith(logs: await _computeLogs()),
|
||||
_ => LogsState(logs: await _computeLogs()),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
var _logs = <ClashLog>[];
|
||||
final _debouncer = CallbackDebouncer(const Duration(milliseconds: 200));
|
||||
LogLevel? _levelFilter;
|
||||
String _filter = "";
|
||||
|
||||
Future<List<ClashLog>> _computeLogs() async {
|
||||
if (_levelFilter == null && _filter.isEmpty) return _logs;
|
||||
return _logs.where((e) {
|
||||
return (_filter.isEmpty || e.message.contains(_filter)) &&
|
||||
(_levelFilter == null || e.level == _levelFilter);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(value.copyWith(logs: [])).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
|
||||
void filterMessage(String? filter) {
|
||||
_filter = filter ?? '';
|
||||
_debouncer(
|
||||
() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
filter: _filter,
|
||||
logs: await _computeLogs(),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> filterLevel(LogLevel? level) async {
|
||||
_levelFilter = level;
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
levelFilter: _levelFilter,
|
||||
logs: await _computeLogs(),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/features/logs/notifier/logs_state.dart
Normal file
15
lib/features/logs/notifier/logs_state.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
|
||||
part 'logs_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class LogsState with _$LogsState {
|
||||
const LogsState._();
|
||||
|
||||
const factory LogsState({
|
||||
@Default([]) List<ClashLog> logs,
|
||||
@Default("") String filter,
|
||||
LogLevel? levelFilter,
|
||||
}) = _LogsState;
|
||||
}
|
||||
2
lib/features/logs/notifier/notifier.dart
Normal file
2
lib/features/logs/notifier/notifier.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'logs_notifier.dart';
|
||||
export 'logs_state.dart';
|
||||
138
lib/features/logs/view/logs_page.dart
Normal file
138
lib/features/logs/view/logs_page.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/logs/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class LogsPage extends HookConsumerWidget {
|
||||
const LogsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final asyncState = ref.watch(logsNotifierProvider);
|
||||
final notifier = ref.watch(logsNotifierProvider.notifier);
|
||||
|
||||
switch (asyncState) {
|
||||
case AsyncData(value: final state):
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TODO: fix height
|
||||
toolbarHeight: 90,
|
||||
title: Text(t.logs.pageTitle.titleCase),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(36),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
onChanged: notifier.filterMessage,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: t.logs.filterHint.sentenceCase,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
DropdownButton<Option<LogLevel>>(
|
||||
value: optionOf(state.levelFilter),
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
notifier.filterLevel(v.toNullable());
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: none(),
|
||||
child: Text(t.logs.allLevelsFilter.sentenceCase),
|
||||
),
|
||||
...LogLevel.values.takeFirst(3).map(
|
||||
(e) => DropdownMenuItem(
|
||||
value: some(e),
|
||||
child: Text(e.name.sentenceCase),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: state.logs.length,
|
||||
reverse: true,
|
||||
itemBuilder: (context, index) {
|
||||
final log = state.logs[index];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: log.timeStamp),
|
||||
const TextSpan(text: " "),
|
||||
TextSpan(
|
||||
text: log.level.name.toUpperCase(),
|
||||
style: TextStyle(color: log.level.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: Text(log.message),
|
||||
),
|
||||
if (index != 0)
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
height: 4,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncError(:final error):
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.logs.pageTitle.titleCase),
|
||||
),
|
||||
SliverErrorBodyPlaceholder(t.presentError(error)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncLoading():
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.logs.pageTitle.titleCase),
|
||||
),
|
||||
const SliverLoadingBodyPlaceholder(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: remove
|
||||
default:
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/features/logs/view/view.dart
Normal file
1
lib/features/logs/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'logs_page.dart';
|
||||
2
lib/features/profile_detail/notifier/notifier.dart
Normal file
2
lib/features/profile_detail/notifier/notifier.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'profile_detail_notifier.dart';
|
||||
export 'profile_detail_state.dart';
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/profile_detail/notifier/profile_detail_state.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'profile_detail_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
@override
|
||||
Future<ProfileDetailState> build(
|
||||
String id, {
|
||||
String? url,
|
||||
String? name,
|
||||
}) async {
|
||||
if (id == 'new') {
|
||||
return ProfileDetailState(
|
||||
profile: Profile(
|
||||
id: const Uuid().v4(),
|
||||
active: true,
|
||||
name: name ?? "",
|
||||
url: url ?? "",
|
||||
lastUpdate: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
final failureOrProfile = await _profilesRepo.get(id).run();
|
||||
return failureOrProfile.match(
|
||||
(l) {
|
||||
loggy.warning('failed to load profile, $l');
|
||||
throw l;
|
||||
},
|
||||
(profile) {
|
||||
if (profile == null) {
|
||||
loggy.warning('profile with id: [$id] does not exist');
|
||||
throw const ProfileNotFoundFailure();
|
||||
}
|
||||
return ProfileDetailState(profile: profile, isEditing: true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider);
|
||||
|
||||
void setField({String? name, String? url}) {
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
profile: value.profile.copyWith(
|
||||
name: name ?? value.profile.name,
|
||||
url: url ?? value.profile.url,
|
||||
),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
if (value.save.isInProgress) return;
|
||||
final profile = value.profile;
|
||||
loggy.debug(
|
||||
'saving profile, url: [${profile.url}], name: [${profile.name}]',
|
||||
);
|
||||
state = AsyncData(value.copyWith(save: const MutationInProgress()))
|
||||
.copyWithPrevious(state);
|
||||
Either<ProfileFailure, Unit>? failureOrSuccess;
|
||||
if (profile.name.isBlank || profile.url.isBlank) {
|
||||
loggy.debug('profile save: invalid arguments');
|
||||
} else if (value.isEditing) {
|
||||
loggy.debug('updating profile');
|
||||
failureOrSuccess = await _profilesRepo.update(profile).run();
|
||||
} else {
|
||||
loggy.debug('adding profile, url: [${profile.url}]');
|
||||
failureOrSuccess = await _profilesRepo.add(profile).run();
|
||||
}
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
save: failureOrSuccess?.fold(
|
||||
(l) => MutationFailure(l),
|
||||
(_) => const MutationSuccess(),
|
||||
) ??
|
||||
value.save,
|
||||
showErrorMessages: true,
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
if (value.delete.isInProgress) return;
|
||||
final profile = value.profile;
|
||||
loggy.debug('deleting profile');
|
||||
state = AsyncData(
|
||||
value.copyWith(delete: const MutationState.inProgress()),
|
||||
).copyWithPrevious(state);
|
||||
final result = await _profilesRepo.delete(profile.id).run();
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
delete: result.match(
|
||||
(l) => MutationFailure(l),
|
||||
(_) => const MutationSuccess(),
|
||||
),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'profile_detail_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ProfileDetailState with _$ProfileDetailState {
|
||||
const ProfileDetailState._();
|
||||
|
||||
const factory ProfileDetailState({
|
||||
required Profile profile,
|
||||
@Default(false) bool isEditing,
|
||||
@Default(false) bool showErrorMessages,
|
||||
@Default(MutationState.initial()) MutationState<ProfileFailure> save,
|
||||
@Default(MutationState.initial()) MutationState<ProfileFailure> delete,
|
||||
}) = _ProfileDetailState;
|
||||
|
||||
bool get isBusy =>
|
||||
(save.isInProgress || save is MutationSuccess) ||
|
||||
(delete.isInProgress || delete is MutationSuccess);
|
||||
}
|
||||
203
lib/features/profile_detail/view/profile_detail_page.dart
Normal file
203
lib/features/profile_detail/view/profile_detail_page.dart
Normal file
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/confirmation_dialogs.dart';
|
||||
import 'package:hiddify/features/profile_detail/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
// TODO: test and improve
|
||||
// TODO: prevent popping screen when busy
|
||||
class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
const ProfileDetailPage(
|
||||
this.id, {
|
||||
super.key,
|
||||
this.url,
|
||||
this.name,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? url;
|
||||
final String? name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = profileDetailNotifierProvider(id, url: url, name: name);
|
||||
final t = ref.watch(translationsProvider);
|
||||
final asyncState = ref.watch(provider);
|
||||
final notifier = ref.watch(provider.notifier);
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
ref.listen(
|
||||
provider.select((data) => data.whenData((value) => value.save)),
|
||||
(_, asyncSave) {
|
||||
if (asyncSave case AsyncData(value: final save)) {
|
||||
switch (save) {
|
||||
case MutationFailure(:final failure):
|
||||
CustomToast.error(t.presentError(failure)).show(context);
|
||||
case MutationSuccess():
|
||||
CustomToast.success(t.profile.save.successMsg.sentenceCase)
|
||||
.show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ref.listen(
|
||||
provider.select((data) => data.whenData((value) => value.delete)),
|
||||
(_, asyncSave) {
|
||||
if (asyncSave case AsyncData(value: final delete)) {
|
||||
switch (delete) {
|
||||
case MutationFailure(:final failure):
|
||||
CustomToast.error(t.presentError(failure)).show(context);
|
||||
case MutationSuccess():
|
||||
CustomToast.success(t.profile.delete.successMsg.sentenceCase)
|
||||
.show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
switch (asyncState) {
|
||||
case AsyncData(value: final state):
|
||||
return Stack(
|
||||
children: [
|
||||
Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
title: Text(t.profile.detailsPageTitle.titleCase),
|
||||
),
|
||||
const SliverGap(8),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: Form(
|
||||
autovalidateMode: state.showErrorMessages
|
||||
? AutovalidateMode.always
|
||||
: AutovalidateMode.disabled,
|
||||
child: SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
const Gap(8),
|
||||
CustomTextFormField(
|
||||
initialValue: state.profile.name,
|
||||
onChanged: (value) =>
|
||||
notifier.setField(name: value),
|
||||
validator: (value) => (value?.isEmpty ?? true)
|
||||
? t.profile.detailsForm.emptyNameMsg
|
||||
: null,
|
||||
label: t.profile.detailsForm.nameHint.titleCase,
|
||||
),
|
||||
const Gap(16),
|
||||
CustomTextFormField(
|
||||
initialValue: state.profile.url,
|
||||
onChanged: (value) =>
|
||||
notifier.setField(url: value),
|
||||
validator: (value) =>
|
||||
(value != null && !isUrl(value))
|
||||
? t.profile.detailsForm.invalidUrlMsg
|
||||
: null,
|
||||
label:
|
||||
t.profile.detailsForm.urlHint.toUpperCase(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
OverflowBar(
|
||||
spacing: 12,
|
||||
overflowAlignment: OverflowBarAlignment.end,
|
||||
children: [
|
||||
if (state.isEditing)
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final deleteConfirmed =
|
||||
await showConfirmationDialog(
|
||||
context,
|
||||
title:
|
||||
t.profile.delete.buttonText.titleCase,
|
||||
message: t.profile.delete.confirmationMsg
|
||||
.sentenceCase,
|
||||
);
|
||||
if (deleteConfirmed) {
|
||||
await notifier.delete();
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStatePropertyAll(
|
||||
themeData.colorScheme.errorContainer,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t.profile.delete.buttonText.titleCase,
|
||||
style: TextStyle(
|
||||
color: themeData
|
||||
.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: notifier.save,
|
||||
child:
|
||||
Text(t.profile.save.buttonText.titleCase),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.isBusy)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// TODO: handle loading and error states
|
||||
default:
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/features/profile_detail/view/view.dart
Normal file
1
lib/features/profile_detail/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profile_detail_page.dart';
|
||||
1
lib/features/profiles/notifier/notifier.dart
Normal file
1
lib/features/profiles/notifier/notifier.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profiles_notifier.dart';
|
||||
38
lib/features/profiles/notifier/profiles_notifier.dart
Normal file
38
lib/features/profiles/notifier/profiles_notifier.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profiles_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
|
||||
@override
|
||||
Stream<List<Profile>> build() {
|
||||
return _profilesRepo
|
||||
.watchAll()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
|
||||
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider);
|
||||
|
||||
Future<void> selectActiveProfile(String id) async {
|
||||
loggy.debug('changing active profile to: [$id]');
|
||||
await _profilesRepo.setAsActive(id).mapLeft((f) {
|
||||
loggy.warning('failed to set [$id] as active profile, $f');
|
||||
throw f;
|
||||
}).run();
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(Profile profile) async {
|
||||
loggy.debug('deleting profile: ${profile.name}');
|
||||
await _profilesRepo.delete(profile.id).mapLeft(
|
||||
(f) {
|
||||
loggy.warning('failed to delete profile, $f');
|
||||
throw f;
|
||||
},
|
||||
).run();
|
||||
}
|
||||
}
|
||||
38
lib/features/profiles/view/profiles_modal.dart
Normal file
38
lib/features/profiles/view/profiles_modal.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/profiles/widgets/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class ProfilesModal extends HookConsumerWidget {
|
||||
const ProfilesModal({
|
||||
super.key,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController? scrollController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncProfiles = ref.watch(profilesNotifierProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
switch (asyncProfiles) {
|
||||
AsyncData(value: final profiles) => SliverList.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final profile = profiles[index];
|
||||
return ProfileTile(profile);
|
||||
},
|
||||
itemCount: profiles.length,
|
||||
),
|
||||
// TODO: handle loading and error
|
||||
_ => const SliverToBoxAdapter(),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/profiles/view/view.dart
Normal file
1
lib/features/profiles/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profiles_modal.dart';
|
||||
187
lib/features/profiles/widgets/profile_tile.dart
Normal file
187
lib/features/profiles/widgets/profile_tile.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/common/confirmation_dialogs.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
|
||||
class ProfileTile extends HookConsumerWidget {
|
||||
const ProfileTile(this.profile, {super.key});
|
||||
|
||||
final Profile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final subInfo = profile.subInfo;
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
final selectActiveMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
final deleteProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 6,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
shadowColor: Colors.transparent,
|
||||
color: profile.active ? themeData.colorScheme.tertiaryContainer : null,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (profile.active || selectActiveMutation.state.isInProgress) return;
|
||||
selectActiveMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.selectActiveProfile(profile.id),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: profile.name,
|
||||
style: themeData.textTheme.titleMedium,
|
||||
),
|
||||
const TextSpan(text: " • "),
|
||||
TextSpan(
|
||||
text: t.profile.subscription.updatedTimeAgo(
|
||||
timeago: timeago.format(profile.lastUpdate),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Gap(12),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 18,
|
||||
onPressed: () async {
|
||||
// await context.push(Routes.profile(profile.id).path);
|
||||
// TODO: temp
|
||||
await ProfileDetailsRoute(profile.id).push(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
iconSize: 18,
|
||||
onPressed: () async {
|
||||
if (deleteProfileMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
final deleteConfirmed =
|
||||
await showConfirmationDialog(
|
||||
context,
|
||||
title: t.profile.delete.buttonText.titleCase,
|
||||
message:
|
||||
t.profile.delete.confirmationMsg.sentenceCase,
|
||||
);
|
||||
if (deleteConfirmed) {
|
||||
deleteProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.deleteProfile(profile),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (subInfo?.isValid ?? false) ...[
|
||||
const Gap(2),
|
||||
Row(
|
||||
children: [
|
||||
if (subInfo!.isExpired)
|
||||
Text(
|
||||
t.profile.subscription.expired,
|
||||
style: themeData.textTheme.titleSmall
|
||||
?.copyWith(color: themeData.colorScheme.error),
|
||||
)
|
||||
else if (subInfo.ratio >= 1)
|
||||
Text(
|
||||
t.profile.subscription.noTraffic,
|
||||
style: themeData.textTheme.titleSmall?.copyWith(
|
||||
color: themeData.colorScheme.error,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
formatExpireDuration(subInfo.remaining),
|
||||
style: themeData.textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
t.profile.subscription.remaining,
|
||||
style: themeData.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatTrafficByteSize(
|
||||
subInfo.consumption,
|
||||
subInfo.total!,
|
||||
),
|
||||
style: themeData.textTheme.titleMedium,
|
||||
),
|
||||
RemainingTrafficIndicator(subInfo.ratio),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/profiles/widgets/widgets.dart
Normal file
1
lib/features/profiles/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profile_tile.dart';
|
||||
43
lib/features/proxies/model/group_with_proxies.dart
Normal file
43
lib/features/proxies/model/group_with_proxies.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:combine/combine.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
|
||||
part 'group_with_proxies.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class GroupWithProxies with _$GroupWithProxies {
|
||||
const GroupWithProxies._();
|
||||
|
||||
const factory GroupWithProxies({
|
||||
required ClashProxyGroup group,
|
||||
required List<ClashProxy> proxies,
|
||||
}) = _GroupWithProxies;
|
||||
|
||||
static Future<List<GroupWithProxies>> fromProxies(
|
||||
List<ClashProxy> proxies,
|
||||
TunnelMode? mode,
|
||||
) async {
|
||||
final stopWatch = Stopwatch()..start();
|
||||
final res = await CombineWorker().execute(
|
||||
() {
|
||||
final result = <GroupWithProxies>[];
|
||||
for (final proxy in proxies) {
|
||||
if (proxy is ClashProxyGroup) {
|
||||
if (mode != TunnelMode.global && proxy.name == "GLOBAL") continue;
|
||||
final current = <ClashProxy>[];
|
||||
for (final name in proxy.all) {
|
||||
current.addAll(proxies.where((e) => e.name == name).toList());
|
||||
}
|
||||
result.add(GroupWithProxies(group: proxy, proxies: current));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
debugPrint(
|
||||
"computed grouped proxies in [${stopWatch.elapsedMilliseconds}ms]",
|
||||
);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
1
lib/features/proxies/model/model.dart
Normal file
1
lib/features/proxies/model/model.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'group_with_proxies.dart';
|
||||
1
lib/features/proxies/notifier/notifier.dart
Normal file
1
lib/features/proxies/notifier/notifier.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'proxies_notifier.dart';
|
||||
74
lib/features/proxies/notifier/proxies_delay_notifier.dart
Normal file
74
lib/features/proxies/notifier/proxies_delay_notifier.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'proxies_delay_notifier.g.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProxiesDelayNotifier extends _$ProxiesDelayNotifier with AppLogger {
|
||||
@override
|
||||
Map<String, int> build() {
|
||||
ref.onDispose(
|
||||
() {
|
||||
loggy.debug("disposing");
|
||||
_currentTest?.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
ref.listen(
|
||||
activeProfileProvider.selectAsync((value) => value?.id),
|
||||
(prev, next) async {
|
||||
if (await prev != await next) ref.invalidateSelf();
|
||||
},
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
ClashFacade get _clash => ref.read(clashFacadeProvider);
|
||||
StreamSubscription? _currentTest;
|
||||
|
||||
Future<void> testDelay(Iterable<String> proxies) async {
|
||||
loggy.debug('testing delay for [${proxies.length}] proxies');
|
||||
|
||||
// cancel possible running test
|
||||
await _currentTest?.cancel();
|
||||
|
||||
// reset previous
|
||||
state = state.filterNot((entry) => proxies.contains(entry.key));
|
||||
|
||||
void setDelay(String name, int delay) {
|
||||
state = {
|
||||
...state
|
||||
..update(
|
||||
name,
|
||||
(_) => delay,
|
||||
ifAbsent: () => delay,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
_currentTest = Stream.fromIterable(proxies)
|
||||
.bufferCount(5)
|
||||
.asyncMap(
|
||||
(chunk) => Future.wait(
|
||||
chunk.map(
|
||||
(e) async => setDelay(
|
||||
e,
|
||||
await _clash.testDelay(e).getOrElse((l) => -1).run(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.listen((event) {});
|
||||
}
|
||||
|
||||
Future<void> cancelDelayTest() async => _currentTest?.cancel();
|
||||
}
|
||||
45
lib/features/proxies/notifier/proxies_notifier.dart
Normal file
45
lib/features/proxies/notifier/proxies_notifier.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/common/clash/clash_controller.dart';
|
||||
import 'package:hiddify/features/common/clash/clash_mode.dart';
|
||||
import 'package:hiddify/features/proxies/model/model.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'proxies_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProxiesNotifier extends _$ProxiesNotifier with AppLogger {
|
||||
@override
|
||||
Future<List<GroupWithProxies>> build() async {
|
||||
loggy.debug('building');
|
||||
await ref.watch(clashControllerProvider.future);
|
||||
final mode = await ref.watch(clashModeProvider.future);
|
||||
return _clash
|
||||
.getProxies()
|
||||
.flatMap(
|
||||
(proxies) {
|
||||
return TaskEither(
|
||||
() async =>
|
||||
right(await GroupWithProxies.fromProxies(proxies, mode)),
|
||||
);
|
||||
},
|
||||
)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
|
||||
ClashFacade get _clash => ref.read(clashFacadeProvider);
|
||||
|
||||
Future<void> changeProxy(String selectorName, String proxyName) async {
|
||||
loggy.debug("changing proxy, selector: $selectorName - proxy: $proxyName ");
|
||||
await _clash
|
||||
.changeProxy(selectorName, proxyName)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
||||
188
lib/features/proxies/view/proxies_page.dart
Normal file
188
lib/features/proxies/view/proxies_page.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/proxies/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/proxies/notifier/proxies_delay_notifier.dart';
|
||||
import 'package:hiddify/features/proxies/widgets/widgets.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
// TODO: rewrite, bugs with scroll
|
||||
class ProxiesPage extends HookConsumerWidget with PresLogger {
|
||||
const ProxiesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final notifier = ref.watch(proxiesNotifierProvider.notifier);
|
||||
final asyncProxies = ref.watch(proxiesNotifierProvider);
|
||||
final proxies = asyncProxies.value ?? [];
|
||||
final delays = ref.watch(proxiesDelayNotifierProvider);
|
||||
|
||||
final selectActiveProxyMutation = useMutation(
|
||||
initialOnFailure: (error) =>
|
||||
CustomToast.error(t.presentError(error)).show(context),
|
||||
);
|
||||
|
||||
final tabController = useTabController(
|
||||
initialLength: proxies.length,
|
||||
keys: [proxies.length],
|
||||
);
|
||||
|
||||
switch (asyncProxies) {
|
||||
case AsyncData(value: final proxies):
|
||||
if (proxies.isEmpty) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t.proxies.emptyProxiesMsg.titleCase),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final tabs = [
|
||||
for (final groupWithProxies in proxies)
|
||||
Tab(
|
||||
child: Text(
|
||||
groupWithProxies.group.name.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
];
|
||||
|
||||
final tabViews = [
|
||||
for (final groupWithProxies in proxies)
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return CustomScrollView(
|
||||
key: PageStorageKey<String>(
|
||||
groupWithProxies.group.name,
|
||||
),
|
||||
slivers: <Widget>[
|
||||
SliverList.builder(
|
||||
itemBuilder: (_, index) {
|
||||
final proxy = groupWithProxies.proxies[index];
|
||||
return ProxyTile(
|
||||
proxy,
|
||||
selected: groupWithProxies.group.now == proxy.name,
|
||||
delay: delays[proxy.name],
|
||||
onSelect: () async {
|
||||
if (selectActiveProxyMutation
|
||||
.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
selectActiveProxyMutation.setFuture(
|
||||
notifier.changeProxy(
|
||||
groupWithProxies.group.name,
|
||||
proxy.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: groupWithProxies.proxies.length,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return <Widget>[
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
forceElevated: innerBoxIsScrolled,
|
||||
actions: [
|
||||
PopupMenuButton(
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
onTap: ref
|
||||
.read(proxiesDelayNotifierProvider.notifier)
|
||||
.cancelDelayTest,
|
||||
child: Text(
|
||||
t.proxies.cancelTestButtonText.sentenceCase,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: tabController,
|
||||
isScrollable: true,
|
||||
tabs: tabs,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
controller: tabController,
|
||||
children: tabViews,
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async =>
|
||||
// TODO: improve
|
||||
ref.read(proxiesDelayNotifierProvider.notifier).testDelay(
|
||||
proxies[tabController.index].proxies.map((e) => e.name),
|
||||
),
|
||||
tooltip: t.proxies.delayTestTooltip.titleCase,
|
||||
child: const Icon(Icons.bolt),
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncError(:final error):
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
),
|
||||
SliverErrorBodyPlaceholder(t.presentError(error)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncLoading():
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
),
|
||||
const SliverLoadingBodyPlaceholder(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: remove
|
||||
default:
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/features/proxies/view/view.dart
Normal file
1
lib/features/proxies/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'proxies_page.dart';
|
||||
33
lib/features/proxies/widgets/proxy_tile.dart
Normal file
33
lib/features/proxies/widgets/proxy_tile.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
class ProxyTile extends HookConsumerWidget {
|
||||
const ProxyTile(
|
||||
this.proxy, {
|
||||
super.key,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
this.delay,
|
||||
});
|
||||
|
||||
final ClashProxy proxy;
|
||||
final bool selected;
|
||||
final VoidCallback onSelect;
|
||||
final int? delay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
proxy.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(proxy.type.label),
|
||||
trailing: delay != null ? Text(delay.toString()) : null,
|
||||
selected: selected,
|
||||
onTap: onSelect,
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/proxies/widgets/widgets.dart
Normal file
1
lib/features/proxies/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'proxy_tile.dart';
|
||||
60
lib/features/settings/view/settings_page.dart
Normal file
60
lib/features/settings/view/settings_page.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/features/settings/widgets/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
const divider = Divider(indent: 16, endIndent: 16);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.settings.pageTitle.titleCase),
|
||||
),
|
||||
body: ListTileTheme(
|
||||
data: ListTileTheme.of(context).copyWith(
|
||||
contentPadding: const EdgeInsetsDirectional.only(start: 48, end: 16),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
_SettingsSectionHeader(
|
||||
t.settings.appearance.sectionTitle.titleCase,
|
||||
),
|
||||
const AppearanceSettingTiles(),
|
||||
divider,
|
||||
_SettingsSectionHeader(t.settings.network.sectionTitle.titleCase),
|
||||
const NetworkSettingTiles(),
|
||||
divider,
|
||||
_SettingsSectionHeader(t.settings.clash.sectionTitle.titleCase),
|
||||
const ClashSettingTiles(),
|
||||
const Gap(16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsSectionHeader extends StatelessWidget {
|
||||
const _SettingsSectionHeader(this.title);
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/settings/view/view.dart
Normal file
1
lib/features/settings/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'settings_page.dart';
|
||||
54
lib/features/settings/widgets/appearance_setting_tiles.dart
Normal file
54
lib/features/settings/widgets/appearance_setting_tiles.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/theme/theme.dart';
|
||||
import 'package:hiddify/features/settings/widgets/theme_mode_switch_button.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class AppearanceSettingTiles extends HookConsumerWidget {
|
||||
const AppearanceSettingTiles({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final theme = ref.watch(themeControllerProvider);
|
||||
final themeController = ref.watch(themeControllerProvider.notifier);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(t.settings.appearance.themeMode.titleCase),
|
||||
subtitle: Text(
|
||||
switch (theme.themeMode) {
|
||||
ThemeMode.system => t.settings.appearance.themeModes.system,
|
||||
ThemeMode.light => t.settings.appearance.themeModes.light,
|
||||
ThemeMode.dark => t.settings.appearance.themeModes.dark,
|
||||
}
|
||||
.sentenceCase,
|
||||
),
|
||||
trailing: ThemeModeSwitch(
|
||||
themeMode: theme.themeMode,
|
||||
onChanged: (value) {
|
||||
themeController.change(themeMode: value);
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
await themeController.change(
|
||||
themeMode: Theme.of(context).brightness == Brightness.light
|
||||
? ThemeMode.dark
|
||||
: ThemeMode.light,
|
||||
);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(t.settings.appearance.trueBlack.titleCase),
|
||||
value: theme.trueBlack,
|
||||
onChanged: (value) {
|
||||
themeController.change(trueBlack: value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
241
lib/features/settings/widgets/clash_setting_tiles.dart
Normal file
241
lib/features/settings/widgets/clash_setting_tiles.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class ClashSettingTiles extends HookConsumerWidget {
|
||||
const ClashSettingTiles({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final overrides =
|
||||
ref.watch(prefsControllerProvider.select((value) => value.clash));
|
||||
final notifier = ref.watch(prefsControllerProvider.notifier);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.httpPort,
|
||||
value: overrides.httpPort,
|
||||
resetValue: ClashConfig.initial.httpPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(httpPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.socksPort,
|
||||
value: overrides.socksPort,
|
||||
resetValue: ClashConfig.initial.socksPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(socksPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.redirPort,
|
||||
value: overrides.redirPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(redirPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.tproxyPort,
|
||||
value: overrides.tproxyPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(tproxyPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.mixedPort,
|
||||
value: overrides.mixedPort,
|
||||
resetValue: ClashConfig.initial.mixedPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(mixedPort: value),
|
||||
),
|
||||
),
|
||||
ToggleOverrideTile(
|
||||
title: t.settings.clash.overrides.allowLan,
|
||||
value: overrides.allowLan,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(allowLan: value),
|
||||
),
|
||||
),
|
||||
ToggleOverrideTile(
|
||||
title: t.settings.clash.overrides.ipv6,
|
||||
value: overrides.ipv6,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(ipv6: value),
|
||||
),
|
||||
),
|
||||
ChoiceOverrideTile(
|
||||
title: t.settings.clash.overrides.mode,
|
||||
value: overrides.mode,
|
||||
options: TunnelMode.values,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(mode: value),
|
||||
),
|
||||
),
|
||||
ChoiceOverrideTile(
|
||||
title: t.settings.clash.overrides.logLevel,
|
||||
value: overrides.logLevel,
|
||||
options: LogLevel.values,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(logLevel: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InputOverrideTile extends HookConsumerWidget {
|
||||
const InputOverrideTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.resetValue,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final int? value;
|
||||
final int? resetValue;
|
||||
final ValueChanged<Option<int>> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium,
|
||||
trailing: Text(
|
||||
value == null
|
||||
? t.settings.clash.doNotModify.sentenceCase
|
||||
: value.toString(),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await SettingsInputDialog<int>(
|
||||
title: title,
|
||||
initialValue: value,
|
||||
resetValue: optionOf(resetValue),
|
||||
).show(context).then(
|
||||
(value) {
|
||||
return value?.match<Option<int>?>(
|
||||
() => none(),
|
||||
(t) {
|
||||
final i = int.tryParse(t);
|
||||
return i == null ? null : some(i);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result == null) return;
|
||||
onChange(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ToggleOverrideTile extends HookConsumerWidget {
|
||||
const ToggleOverrideTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final bool? value;
|
||||
final ValueChanged<Option<bool>> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return PopupMenuButton<Option<bool>>(
|
||||
initialValue: optionOf(value),
|
||||
onSelected: onChange,
|
||||
child: ListTile(
|
||||
title: Text(title),
|
||||
leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium,
|
||||
trailing: Text(
|
||||
(value == null
|
||||
? t.settings.clash.doNotModify
|
||||
: value!
|
||||
? t.general.toggle.enabled
|
||||
: t.general.toggle.disabled)
|
||||
.sentenceCase,
|
||||
),
|
||||
),
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: none(),
|
||||
child: Text(t.settings.clash.doNotModify.sentenceCase),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: some(true),
|
||||
child: Text(t.general.toggle.enabled.sentenceCase),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: some(false),
|
||||
child: Text(t.general.toggle.disabled.sentenceCase),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceOverrideTile<T extends Enum> extends HookConsumerWidget {
|
||||
const ChoiceOverrideTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final T? value;
|
||||
final List<T> options;
|
||||
final ValueChanged<Option<T>> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return PopupMenuButton<Option<T>>(
|
||||
initialValue: optionOf(value),
|
||||
onSelected: onChange,
|
||||
child: ListTile(
|
||||
title: Text(title),
|
||||
leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium,
|
||||
trailing: Text(
|
||||
(value == null ? t.settings.clash.doNotModify : value!.name)
|
||||
.sentenceCase,
|
||||
),
|
||||
),
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: none(),
|
||||
child: Text(t.settings.clash.doNotModify.sentenceCase),
|
||||
),
|
||||
...options.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: some(e),
|
||||
child: Text(e.name.sentenceCase),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user