This commit is contained in:
problematicconsumer
2023-07-06 17:18:41 +03:30
commit b617c95f62
352 changed files with 21017 additions and 0 deletions

80
lib/bootstrap.dart Normal file
View 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
View File

@@ -0,0 +1 @@
export 'app_view.dart';

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

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

View File

@@ -0,0 +1,2 @@
export 'locale_controller.dart';
export 'locale_pref.dart';

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

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

View File

@@ -0,0 +1,2 @@
export 'prefs_controller.dart';
export 'prefs_state.dart';

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

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

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

View File

@@ -0,0 +1,2 @@
export 'app_router.dart';
export 'routes/routes.dart';

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

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

View 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,
];

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

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

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

View File

@@ -0,0 +1,4 @@
export 'app_theme.dart';
export 'constants.dart';
export 'theme_controller.dart';
export 'theme_prefs.dart';

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

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

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

View File

@@ -0,0 +1 @@
export 'profiles_dao.dart';

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,2 @@
export 'clash_facade_impl.dart';
export 'profiles_repository_impl.dart';

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
export 'connection_status.dart';
export 'connectivity_failure.dart';
export 'network_prefs.dart';
export 'traffic.dart';

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
export 'profile.dart';
export 'profiles_failure.dart';
export 'profiles_repository.dart';
export 'subscription_info.dart';

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

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

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

View File

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

View File

@@ -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));
}

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

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

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

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

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

View File

@@ -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:
}
}
}

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export 'home_page.dart';

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

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

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

View File

@@ -0,0 +1,3 @@
export 'active_profile_card.dart';
export 'connection_button.dart';
export 'empty_profiles_home_body.dart';

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

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

View File

@@ -0,0 +1,2 @@
export 'logs_notifier.dart';
export 'logs_state.dart';

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

View File

@@ -0,0 +1 @@
export 'logs_page.dart';

View File

@@ -0,0 +1,2 @@
export 'profile_detail_notifier.dart';
export 'profile_detail_state.dart';

View File

@@ -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);
}
}
}

View File

@@ -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);
}

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

View File

@@ -0,0 +1 @@
export 'profile_detail_page.dart';

View File

@@ -0,0 +1 @@
export 'profiles_notifier.dart';

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

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

View File

@@ -0,0 +1 @@
export 'profiles_modal.dart';

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

View File

@@ -0,0 +1 @@
export 'profile_tile.dart';

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

View File

@@ -0,0 +1 @@
export 'group_with_proxies.dart';

View File

@@ -0,0 +1 @@
export 'proxies_notifier.dart';

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

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

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

View File

@@ -0,0 +1 @@
export 'proxies_page.dart';

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

View File

@@ -0,0 +1 @@
export 'proxy_tile.dart';

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

View File

@@ -0,0 +1 @@
export 'settings_page.dart';

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

View 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