Refactor preferences

This commit is contained in:
problematicconsumer
2024-03-02 22:53:14 +03:30
parent 201ea5e88d
commit 2a994dc348
32 changed files with 1104 additions and 1389 deletions

View File

@@ -99,7 +99,7 @@ Future<void> lazyBootstrap(
() => container.read(windowNotifierProvider.future),
);
final silentStart = container.read(silentStartNotifierProvider);
final silentStart = container.read(Preferences.silentStart);
Logger.bootstrap
.debug("silent start [${silentStart ? "Enabled" : "Disabled"}]");
if (!silentStart) {

View File

@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:hiddify/core/app_info/app_info_provider.dart';
import 'package:hiddify/core/http_client/dio_http_client.dart';
import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'http_client_provider.g.dart';
@@ -15,9 +15,9 @@ DioHttpClient httpClient(HttpClientRef ref) {
);
ref.listen(
configOptionNotifierProvider.selectAsync((data) => data.mixedPort),
ConfigOptions.mixedPort,
(_, next) async {
client.setProxyPort(await next);
client.setProxyPort(next);
},
fireImmediately: true,
);

View File

@@ -16,9 +16,9 @@ class OptionalRange with OptionalRangeMappable {
String present(TranslationsEn t) =>
format().isEmpty ? t.general.notSet : format();
factory OptionalRange._fromString(
factory OptionalRange.parse(
String input, {
bool allowEmpty = true,
bool allowEmpty = false,
}) =>
switch (input.split("-")) {
[final String val] when val.isEmpty && allowEmpty =>
@@ -36,7 +36,7 @@ class OptionalRange with OptionalRangeMappable {
bool allowEmpty = false,
}) {
try {
return OptionalRange._fromString(input);
return OptionalRange.parse(input, allowEmpty: allowEmpty);
} catch (_) {
return null;
}
@@ -48,7 +48,8 @@ class OptionalRangeJsonConverter
const OptionalRangeJsonConverter();
@override
OptionalRange fromJson(String json) => OptionalRange._fromString(json);
OptionalRange fromJson(String json) =>
OptionalRange.parse(json, allowEmpty: true);
@override
String toJson(OptionalRange object) => object.format();

View File

@@ -3,203 +3,111 @@ import 'package:hiddify/core/app_info/app_info_provider.dart';
import 'package:hiddify/core/model/environment.dart';
import 'package:hiddify/core/model/region.dart';
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/per_app_proxy/model/per_app_proxy_mode.dart';
import 'package:hiddify/utils/platform_utils.dart';
import 'package:hiddify/utils/pref_notifier.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'general_preferences.g.dart';
// TODO refactor
bool _debugIntroPage = false;
@Riverpod(keepAlive: true)
class IntroCompleted extends _$IntroCompleted {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
abstract class Preferences {
static final introCompleted = PreferencesNotifier.create(
"intro_completed",
false,
overrideValue: _debugIntroPage && kDebugMode ? false : null,
);
@override
bool build() {
if (_debugIntroPage && kDebugMode) return false;
return _pref.getValue();
}
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}
@Riverpod(keepAlive: true)
class RegionNotifier extends _$RegionNotifier {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
static final region = PreferencesNotifier.create<Region, String>(
"region",
Region.other,
mapFrom: Region.values.byName,
mapTo: (value) => value.name,
);
@override
Region build() => _pref.getValue();
Future<void> update(Region value) {
state = value;
return _pref.update(value);
}
}
@Riverpod(keepAlive: true)
class SilentStartNotifier extends _$SilentStartNotifier {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
static final silentStart = PreferencesNotifier.create<bool, bool>(
"silent_start",
false,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}
@Riverpod(keepAlive: true)
class DisableMemoryLimit extends _$DisableMemoryLimit {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
static final disableMemoryLimit = PreferencesNotifier.create<bool, bool>(
"disable_memory_limit",
// disable memory limit on desktop by default
PlatformUtils.isDesktop,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}
@Riverpod(keepAlive: true)
class DebugModeNotifier extends _$DebugModeNotifier {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"debug_mode",
ref.read(environmentProvider) == Environment.dev,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}
@Riverpod(keepAlive: true)
class PerAppProxyModeNotifier extends _$PerAppProxyModeNotifier {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
static final perAppProxyMode =
PreferencesNotifier.create<PerAppProxyMode, String>(
"per_app_proxy_mode",
PerAppProxyMode.off,
mapFrom: PerAppProxyMode.values.byName,
mapTo: (value) => value.name,
);
@override
PerAppProxyMode build() => _pref.getValue();
static final markNewProfileActive = PreferencesNotifier.create<bool, bool>(
"mark_new_profile_active",
true,
);
Future<void> update(PerAppProxyMode value) {
static final dynamicNotification = PreferencesNotifier.create<bool, bool>(
"dynamic_notification",
true,
);
static final autoCheckIp = PreferencesNotifier.create<bool, bool>(
"auto_check_ip",
true,
);
static final startedByUser = PreferencesNotifier.create<bool, bool>(
"started_by_user",
false,
);
}
@Riverpod(keepAlive: true)
class DebugModeNotifier extends _$DebugModeNotifier {
late final _pref = PreferencesEntry(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
key: "debug_mode",
defaultValue: ref.read(environmentProvider) == Environment.dev,
);
@override
bool build() => _pref.read();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
return _pref.write(value);
}
}
@Riverpod(keepAlive: true)
class PerAppProxyList extends _$PerAppProxyList {
late final _include = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"per_app_proxy_include_list",
<String>[],
late final _include = PreferencesEntry(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
key: "per_app_proxy_include_list",
defaultValue: <String>[],
);
late final _exclude = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"per_app_proxy_exclude_list",
<String>[],
late final _exclude = PreferencesEntry(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
key: "per_app_proxy_exclude_list",
defaultValue: <String>[],
);
@override
List<String> build() =>
ref.watch(perAppProxyModeNotifierProvider) == PerAppProxyMode.include
? _include.getValue()
: _exclude.getValue();
ref.watch(Preferences.perAppProxyMode) == PerAppProxyMode.include
? _include.read()
: _exclude.read();
Future<void> update(List<String> value) {
state = value;
if (ref.read(perAppProxyModeNotifierProvider) == PerAppProxyMode.include) {
return _include.update(value);
if (ref.read(Preferences.perAppProxyMode) == PerAppProxyMode.include) {
return _include.write(value);
}
return _exclude.update(value);
}
}
@riverpod
class MarkNewProfileActive extends _$MarkNewProfileActive {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"mark_new_profile_active",
true,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}
@riverpod
class DynamicNotification extends _$DynamicNotification {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"dynamic_notification",
true,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}
@riverpod
class AutoCheckIp extends _$AutoCheckIp {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"auto_check_ip",
true,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
return _exclude.write(value);
}
}

View File

@@ -1,23 +0,0 @@
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/utils/pref_notifier.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'service_preferences.g.dart';
@Riverpod(keepAlive: true)
class StartedByUser extends _$StartedByUser with AppLogger {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"started_by_user",
false,
);
@override
bool build() => _pref.getValue();
Future<void> update(bool value) {
state = value;
return _pref.update(value);
}
}

View File

@@ -85,7 +85,7 @@ class RouterListenable extends _$RouterListenable
@override
Future<void> build() async {
_introCompleted = ref.watch(introCompletedProvider);
_introCompleted = ref.watch(Preferences.introCompleted);
ref.listenSelf((_, __) {
if (state.isLoading) return;

View File

@@ -0,0 +1,156 @@
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PreferencesEntry<T, P> with InfraLogger {
PreferencesEntry({
required this.preferences,
required this.key,
required this.defaultValue,
this.mapFrom,
this.mapTo,
this.validator,
});
final SharedPreferences preferences;
final String key;
final T defaultValue;
final T Function(P value)? mapFrom;
final P Function(T value)? mapTo;
final bool Function(T value)? validator;
T read() {
try {
loggy.debug("getting persisted preference [$key]($T)");
final T value;
if (mapFrom != null) {
final persisted = preferences.get(key) as P?;
if (persisted == null) {
value = defaultValue;
} else {
value = mapFrom!(persisted);
}
} else if (T == List<String>) {
value = preferences.getStringList(key) as T? ?? defaultValue;
} else {
value = preferences.get(key) as T? ?? defaultValue;
}
if (validator?.call(value) ?? true) return value;
return defaultValue;
} catch (e, stackTrace) {
loggy.warning("error getting preference[$key]: $e", e, stackTrace);
return defaultValue;
}
}
Future<bool> write(T value) async {
Object? mapped = value;
if (mapTo != null) {
mapped = mapTo!(value);
}
loggy.debug("updating preference [$key]($T) to [$mapped]");
try {
if (!(validator?.call(value) ?? true)) {
loggy.warning("invalid value [$value] for preference [$key]($T)");
return false;
}
return switch (mapped) {
final String value => await preferences.setString(key, value),
final bool value => await preferences.setBool(key, value),
final int value => await preferences.setInt(key, value),
final double value => await preferences.setDouble(key, value),
final List<String> value => await preferences.setStringList(key, value),
_ => throw const FormatException("Invalid Type"),
};
} catch (e, stackTrace) {
loggy.warning("error updating preference[$key]: $e", e, stackTrace);
return false;
}
}
Future<void> remove() async {
try {
await preferences.remove(key);
} catch (e, stackTrace) {
loggy.warning("error removing preference[$key]: $e", e, stackTrace);
}
}
}
class PreferencesNotifier<T, P> extends StateNotifier<T> {
PreferencesNotifier._({
required Ref ref,
required this.entry,
this.overrideValue,
}) : _ref = ref,
super(overrideValue ?? entry.read());
final Ref _ref;
final PreferencesEntry<T, P> entry;
final T? overrideValue;
static StateNotifierProvider<PreferencesNotifier<T, P>, T> create<T, P>(
String key,
T defaultValue, {
T Function(P value)? mapFrom,
P Function(T value)? mapTo,
bool Function(T value)? validator,
T? overrideValue,
}) =>
StateNotifierProvider(
(ref) => PreferencesNotifier._(
ref: ref,
entry: PreferencesEntry<T, P>(
preferences: ref.read(sharedPreferencesProvider).requireValue,
key: key,
defaultValue: defaultValue,
mapFrom: mapFrom,
mapTo: mapTo,
validator: validator,
),
overrideValue: overrideValue,
),
);
static AutoDisposeStateNotifierProvider<PreferencesNotifier<T, P>, T>
createAutoDispose<T, P>(
String key,
T defaultValue, {
T Function(P value)? mapFrom,
P Function(T value)? mapTo,
bool Function(T value)? validator,
T? overrideValue,
}) =>
StateNotifierProvider.autoDispose(
(ref) => PreferencesNotifier._(
ref: ref,
entry: PreferencesEntry<T, P>(
preferences: ref.read(sharedPreferencesProvider).requireValue,
key: key,
defaultValue: defaultValue,
mapFrom: mapFrom,
mapTo: mapTo,
validator: validator,
),
overrideValue: overrideValue,
),
);
P raw() {
final value = overrideValue ?? state;
if (entry.mapTo != null) return entry.mapTo!(value);
return value as P;
}
Future<void> update(T value) async {
if (await entry.write(value)) state = value;
}
Future<void> reset() async {
await entry.remove();
_ref.invalidateSelf();
}
}

View File

@@ -3,11 +3,11 @@ import 'package:hiddify/core/app_info/app_info_provider.dart';
import 'package:hiddify/core/localization/locale_preferences.dart';
import 'package:hiddify/core/model/constants.dart';
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/app_update/data/app_update_data_providers.dart';
import 'package:hiddify/features/app_update/model/app_update_failure.dart';
import 'package:hiddify/features/app_update/model/remote_version_entity.dart';
import 'package:hiddify/features/app_update/notifier/app_update_state.dart';
import 'package:hiddify/utils/pref_notifier.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:upgrader/upgrader.dart';
@@ -32,10 +32,10 @@ class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger {
@override
AppUpdateState build() => const AppUpdateState.initial();
Pref<String?, dynamic> get _ignoreReleasePref => Pref(
ref.read(sharedPreferencesProvider).requireValue,
'ignored_release_version',
null,
PreferencesEntry<String?, dynamic> get _ignoreReleasePref => PreferencesEntry(
preferences: ref.read(sharedPreferencesProvider).requireValue,
key: 'ignored_release_version',
defaultValue: null,
);
Future<AppUpdateState> check() async {
@@ -58,7 +58,7 @@ class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger {
final latestVersion = Version.parse(remote.version);
final currentVersion = Version.parse(appInfo.version);
if (latestVersion > currentVersion) {
if (remote.version == _ignoreReleasePref.getValue()) {
if (remote.version == _ignoreReleasePref.read()) {
loggy.debug("ignored release [${remote.version}]");
return state = AppUpdateStateIgnored(remote);
}
@@ -81,7 +81,7 @@ class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger {
Future<void> ignoreRelease(RemoteVersionEntity version) async {
loggy.debug("ignoring release [${version.version}]");
await _ignoreReleasePref.update(version.version);
await _ignoreReleasePref.write(version.version);
state = AppUpdateStateIgnored(version);
}
}

View File

@@ -57,7 +57,7 @@ class RegionPrefTile extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final region = ref.watch(regionNotifierProvider);
final region = ref.watch(Preferences.region);
return ListTile(
title: Text(t.settings.general.region),
@@ -83,9 +83,7 @@ class RegionPrefTile extends HookConsumerWidget {
},
);
if (selectedRegion != null) {
await ref
.read(regionNotifierProvider.notifier)
.update(selectedRegion);
await ref.read(Preferences.region.notifier).update(selectedRegion);
}
},
);

View File

@@ -1,7 +1,6 @@
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart';
import 'package:hiddify/singbox/service/singbox_service_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'config_option_data_providers.g.dart';
@@ -10,19 +9,9 @@ part 'config_option_data_providers.g.dart';
ConfigOptionRepository configOptionRepository(
ConfigOptionRepositoryRef ref,
) {
return ConfigOptionRepositoryImpl(
return ConfigOptionRepository(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
singbox: ref.watch(singboxServiceProvider),
);
}
@Riverpod(keepAlive: true)
SingBoxConfigOptionRepository singBoxConfigOptionRepository(
SingBoxConfigOptionRepositoryRef ref,
) {
return SingBoxConfigOptionRepositoryImpl(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
optionsRepository: ref.watch(configOptionRepositoryProvider),
getConfigOptions: () => ConfigOptions.singboxOptions(ref),
geoAssetRepository: ref.watch(geoAssetRepositoryProvider).requireValue,
geoAssetPathResolver: ref.watch(geoAssetPathResolverProvider),
);

View File

@@ -1,166 +1,402 @@
import 'package:dartx/dartx.dart';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/model/region.dart';
import 'package:hiddify/core/utils/exception_handler.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/core/utils/json_converters.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/config_option/model/config_option_failure.dart';
import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart';
import 'package:hiddify/features/geo_asset/data/geo_asset_repository.dart';
import 'package:hiddify/features/log/model/log_level.dart';
import 'package:hiddify/singbox/model/singbox_config_enum.dart';
import 'package:hiddify/singbox/model/singbox_config_option.dart';
import 'package:hiddify/singbox/model/singbox_rule.dart';
import 'package:hiddify/singbox/service/singbox_service.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:meta/meta.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract interface class ConfigOptionRepository {
Either<ConfigOptionFailure, ConfigOptionEntity> getConfigOption();
TaskEither<ConfigOptionFailure, Unit> updateConfigOption(
ConfigOptionPatch patch,
abstract class ConfigOptions {
static final serviceMode = PreferencesNotifier.create<ServiceMode, String>(
"service-mode",
ServiceMode.defaultMode,
mapFrom: (value) => ServiceMode.choices.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
TaskEither<ConfigOptionFailure, Unit> resetConfigOption();
TaskEither<ConfigOptionFailure, String> generateWarpConfig();
}
abstract interface class SingBoxConfigOptionRepository {
TaskEither<ConfigOptionFailure, SingboxConfigOption>
getFullSingboxConfigOption();
}
static final logLevel = PreferencesNotifier.create<LogLevel, String>(
"log-level",
LogLevel.warn,
mapFrom: LogLevel.values.byName,
mapTo: (value) => value.name,
);
class ConfigOptionRepositoryImpl
with ExceptionHandler, InfraLogger
implements ConfigOptionRepository {
ConfigOptionRepositoryImpl({
required this.preferences,
required this.singbox,
});
static final resolveDestination = PreferencesNotifier.create<bool, bool>(
"resolve-destination",
false,
);
final SharedPreferences preferences;
final SingboxService singbox;
static final ipv6Mode = PreferencesNotifier.create<IPv6Mode, String>(
"ipv6-mode",
IPv6Mode.disable,
mapFrom: (value) => IPv6Mode.values.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
@override
Either<ConfigOptionFailure, ConfigOptionEntity> getConfigOption() {
try {
final map = ConfigOptionEntity.initial().toJson();
for (final key in map.keys) {
final persisted = preferences.get(key);
if (persisted != null) {
final defaultValue = map[key];
if (defaultValue != null &&
persisted.runtimeType != defaultValue.runtimeType) {
loggy.warning(
"error getting preference[$key], expected type: [${defaultValue.runtimeType}] - received value: [$persisted](${persisted.runtimeType})",
);
continue;
}
map[key] = persisted;
}
static final remoteDnsAddress = PreferencesNotifier.create<String, String>(
"remote-dns-address",
"udp://1.1.1.1",
validator: (value) => value.isNotBlank,
);
static final remoteDnsDomainStrategy =
PreferencesNotifier.create<DomainStrategy, String>(
"remote-dns-domain-strategy",
DomainStrategy.auto,
mapFrom: (value) => DomainStrategy.values.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
static final directDnsAddress = PreferencesNotifier.create<String, String>(
"direct-dns-address",
"1.1.1.1",
validator: (value) => value.isNotBlank,
);
static final directDnsDomainStrategy =
PreferencesNotifier.create<DomainStrategy, String>(
"direct-dns-domain-strategy",
DomainStrategy.auto,
mapFrom: (value) => DomainStrategy.values.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
static final mixedPort = PreferencesNotifier.create<int, int>(
"mixed-port",
2334,
validator: (value) => isPort(value.toString()),
);
static final localDnsPort = PreferencesNotifier.create<int, int>(
"local-dns-port",
6450,
validator: (value) => isPort(value.toString()),
);
static final tunImplementation =
PreferencesNotifier.create<TunImplementation, String>(
"tun-implementation",
TunImplementation.mixed,
mapFrom: TunImplementation.values.byName,
mapTo: (value) => value.name,
);
static final mtu = PreferencesNotifier.create<int, int>("mtu", 9000);
static final strictRoute =
PreferencesNotifier.create<bool, bool>("strict-route", true);
static final connectionTestUrl = PreferencesNotifier.create<String, String>(
"connection-test-url",
"http://cp.cloudflare.com/",
validator: (value) => value.isNotBlank && isUrl(value),
);
static final urlTestInterval = PreferencesNotifier.create<Duration, int>(
"url-test-interval",
const Duration(minutes: 10),
mapFrom: const IntervalInSecondsConverter().fromJson,
mapTo: const IntervalInSecondsConverter().toJson,
);
static final enableClashApi = PreferencesNotifier.create<bool, bool>(
"enable-clash-api",
true,
);
static final clashApiPort = PreferencesNotifier.create<int, int>(
"clash-api-port",
6756,
validator: (value) => isPort(value.toString()),
);
static final bypassLan =
PreferencesNotifier.create<bool, bool>("bypass-lan", false);
static final allowConnectionFromLan = PreferencesNotifier.create<bool, bool>(
"allow-connection-from-lan",
false,
);
static final enableFakeDns = PreferencesNotifier.create<bool, bool>(
"enable-fake-dns",
false,
);
static final enableDnsRouting = PreferencesNotifier.create<bool, bool>(
"enable-dns-routing",
true,
);
static final independentDnsCache = PreferencesNotifier.create<bool, bool>(
"independent-dns-cache",
true,
);
static final enableTlsFragment = PreferencesNotifier.create<bool, bool>(
"enable-tls-fragment",
false,
);
static final tlsFragmentSize =
PreferencesNotifier.create<OptionalRange, String>(
"tls-fragment-size",
const OptionalRange(min: 1, max: 500),
mapFrom: OptionalRange.parse,
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final tlsFragmentSleep =
PreferencesNotifier.create<OptionalRange, String>(
"tls-fragment-sleep",
const OptionalRange(min: 0, max: 500),
mapFrom: OptionalRange.parse,
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final enableTlsMixedSniCase = PreferencesNotifier.create<bool, bool>(
"enable-tls-mixed-sni-case",
false,
);
static final enableTlsPadding = PreferencesNotifier.create<bool, bool>(
"enable-tls-padding",
false,
);
static final tlsPaddingSize =
PreferencesNotifier.create<OptionalRange, String>(
"tls-padding-size",
const OptionalRange(min: 1, max: 1500),
mapFrom: OptionalRange.parse,
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final enableMux = PreferencesNotifier.create<bool, bool>(
"enable-mux",
false,
);
static final muxPadding = PreferencesNotifier.create<bool, bool>(
"mux-padding",
false,
);
static final muxMaxStreams = PreferencesNotifier.create<int, int>(
"mux-max-streams",
8,
validator: (value) => value > 0,
);
static final muxProtocol = PreferencesNotifier.create<MuxProtocol, String>(
"mux-protocol",
MuxProtocol.h2mux,
mapFrom: MuxProtocol.values.byName,
mapTo: (value) => value.name,
);
static final enableWarp = PreferencesNotifier.create<bool, bool>(
"enable-warp",
false,
);
static final warpDetourMode =
PreferencesNotifier.create<WarpDetourMode, String>(
"warp-detour-mode",
WarpDetourMode.outbound,
mapFrom: WarpDetourMode.values.byName,
mapTo: (value) => value.name,
);
static final warpLicenseKey = PreferencesNotifier.create<String, String>(
"warp-license-key",
"",
);
static final warpAccountId = PreferencesNotifier.create<String, String>(
"warp-account-id",
"",
);
static final warpAccessToken = PreferencesNotifier.create<String, String>(
"warp-access-token",
"",
);
static final warpCleanIp = PreferencesNotifier.create<String, String>(
"warp-clean-ip",
"auto",
);
static final warpPort = PreferencesNotifier.create<int, int>(
"warp-port",
0,
validator: (value) => isPort(value.toString()),
);
static final warpNoise = PreferencesNotifier.create<OptionalRange, String>(
"warp-noise",
const OptionalRange(min: 5, max: 10),
mapFrom: (value) => OptionalRange.parse(value, allowEmpty: true),
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final warpNoiseDelay =
PreferencesNotifier.create<OptionalRange, String>(
"warp-noise-delay",
const OptionalRange(min: 20, max: 200),
mapFrom: (value) => OptionalRange.parse(value, allowEmpty: true),
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final warpWireguardConfig = PreferencesNotifier.create<String, String>(
"warp-wireguard-config",
"",
);
static final hasExperimentalFeatures = Provider.autoDispose<bool>(
(ref) {
final mode = ref.watch(serviceMode);
if (PlatformUtils.isDesktop && mode == ServiceMode.tun) {
return true;
}
final options = ConfigOptionEntity.fromJson(map);
return right(options);
} catch (error, stackTrace) {
return left(ConfigOptionUnexpectedFailure(error, stackTrace));
}
}
@override
TaskEither<ConfigOptionFailure, Unit> updateConfigOption(
ConfigOptionPatch patch,
) {
return exceptionHandler(
() async {
final map = patch.toJson();
await updateByJson(map);
return right(unit);
},
ConfigOptionUnexpectedFailure.new,
);
}
@override
TaskEither<ConfigOptionFailure, Unit> resetConfigOption() {
return exceptionHandler(
() async {
final map = ConfigOptionEntity.initial().toJson();
await updateByJson(map);
return right(unit);
},
ConfigOptionUnexpectedFailure.new,
);
}
@visibleForTesting
Future<void> updateByJson(
Map<String, dynamic> options,
) async {
final map = ConfigOptionEntity.initial().toJson();
for (final key in map.keys) {
final value = options[key];
if (value != null) {
loggy.debug("updating [$key] to [$value]");
switch (value) {
case bool _:
await preferences.setBool(key, value);
case String _:
await preferences.setString(key, value);
case int _:
await preferences.setInt(key, value);
case double _:
await preferences.setDouble(key, value);
default:
loggy.warning("unexpected type");
}
if (ref.watch(enableTlsFragment) ||
ref.watch(enableTlsMixedSniCase) ||
ref.watch(enableTlsPadding) ||
ref.watch(enableMux) ||
ref.watch(enableWarp)) {
return true;
}
}
}
@override
TaskEither<ConfigOptionFailure, String> generateWarpConfig() {
return exceptionHandler(
() async {
final options = getConfigOption().getOrElse((l) => throw l);
return await singbox
.generateWarpConfig(
licenseKey: options.warpLicenseKey,
previousAccountId: options.warpAccountId,
previousAccessToken: options.warpAccessToken,
)
.mapLeft((l) => ConfigOptionFailure.unexpected(l))
.flatMap(
(warp) => updateConfigOption(
ConfigOptionPatch(
warpAccountId: warp.accountId,
warpAccessToken: warp.accessToken,
warpWireguardConfig: warp.wireguardConfig,
),
).map((_) => warp.log),
)
.run();
},
(error, stackTrace) {
loggy.error(error);
return ConfigOptionUnexpectedFailure(error, stackTrace);
},
return false;
},
);
/// list of all config option preferences
static final preferences = [
serviceMode,
logLevel,
resolveDestination,
ipv6Mode,
remoteDnsAddress,
remoteDnsDomainStrategy,
directDnsAddress,
directDnsDomainStrategy,
mixedPort,
localDnsPort,
tunImplementation,
mtu,
strictRoute,
connectionTestUrl,
urlTestInterval,
clashApiPort,
bypassLan,
allowConnectionFromLan,
enableDnsRouting,
enableTlsFragment,
tlsFragmentSize,
tlsFragmentSleep,
enableTlsMixedSniCase,
enableTlsPadding,
tlsPaddingSize,
enableMux,
muxPadding,
muxMaxStreams,
muxProtocol,
enableWarp,
warpDetourMode,
warpLicenseKey,
warpAccountId,
warpAccessToken,
warpCleanIp,
warpPort,
warpNoise,
warpWireguardConfig,
];
/// singbox options
///
/// **this is partial, don't use it directly**
static SingboxConfigOption singboxOptions(ProviderRef ref) {
final mode = ref.read(serviceMode);
return SingboxConfigOption(
executeConfigAsIs: false,
logLevel: ref.read(logLevel),
resolveDestination: ref.read(resolveDestination),
ipv6Mode: ref.read(ipv6Mode),
remoteDnsAddress: ref.read(remoteDnsAddress),
remoteDnsDomainStrategy: ref.read(remoteDnsDomainStrategy),
directDnsAddress: ref.read(directDnsAddress),
directDnsDomainStrategy: ref.read(directDnsDomainStrategy),
mixedPort: ref.read(mixedPort),
localDnsPort: ref.read(localDnsPort),
tunImplementation: ref.read(tunImplementation),
mtu: ref.read(mtu),
strictRoute: ref.read(strictRoute),
connectionTestUrl: ref.read(connectionTestUrl),
urlTestInterval: ref.read(urlTestInterval),
enableClashApi: ref.read(enableClashApi),
clashApiPort: ref.read(clashApiPort),
enableTun: mode == ServiceMode.tun,
enableTunService: mode == ServiceMode.tunService,
setSystemProxy: mode == ServiceMode.systemProxy,
bypassLan: ref.read(bypassLan),
allowConnectionFromLan: ref.read(allowConnectionFromLan),
enableFakeDns: ref.read(enableFakeDns),
enableDnsRouting: ref.read(enableDnsRouting),
independentDnsCache: ref.read(independentDnsCache),
enableTlsFragment: ref.read(enableTlsFragment),
tlsFragmentSize: ref.read(tlsFragmentSize),
tlsFragmentSleep: ref.read(tlsFragmentSleep),
enableTlsMixedSniCase: ref.read(enableTlsMixedSniCase),
enableTlsPadding: ref.read(enableTlsPadding),
tlsPaddingSize: ref.read(tlsPaddingSize),
enableMux: ref.read(enableMux),
muxPadding: ref.read(muxPadding),
muxMaxStreams: ref.read(muxMaxStreams),
muxProtocol: ref.read(muxProtocol),
warp: SingboxWarpOption(
enable: ref.read(enableWarp),
mode: ref.read(warpDetourMode),
wireguardConfig: ref.read(warpWireguardConfig),
licenseKey: ref.read(warpLicenseKey),
accountId: ref.read(warpAccountId),
accessToken: ref.read(warpAccessToken),
cleanIp: ref.read(warpCleanIp),
cleanPort: ref.read(warpPort),
warpNoise: ref.read(warpNoise),
warpNoiseDelay: ref.read(warpNoiseDelay),
),
geoipPath: "",
geositePath: "",
rules: [],
);
}
}
class SingBoxConfigOptionRepositoryImpl
with ExceptionHandler, InfraLogger
implements SingBoxConfigOptionRepository {
SingBoxConfigOptionRepositoryImpl({
class ConfigOptionRepository with ExceptionHandler, InfraLogger {
ConfigOptionRepository({
required this.preferences,
required this.optionsRepository,
required this.getConfigOptions,
required this.geoAssetRepository,
required this.geoAssetPathResolver,
});
final SharedPreferences preferences;
final ConfigOptionRepository optionsRepository;
final SingboxConfigOption Function() getConfigOptions;
final GeoAssetRepository geoAssetRepository;
final GeoAssetPathResolver geoAssetPathResolver;
@override
TaskEither<ConfigOptionFailure, SingboxConfigOption>
getFullSingboxConfigOption() {
return exceptionHandler(
@@ -204,9 +440,7 @@ class SingBoxConfigOptionRepositoryImpl
.getOrElse((l) => throw l)
.run();
final persisted =
optionsRepository.getConfigOption().getOrElse((l) => throw l);
final singboxConfigOption = persisted.toSingbox(
final singboxConfigOption = getConfigOptions().copyWith(
geoipPath: geoAssetPathResolver.relativePath(
geoAssets.geoip.providerName,
geoAssets.geoip.fileName,

View File

@@ -1,267 +0,0 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/utils/json_converters.dart';
import 'package:hiddify/features/log/model/log_level.dart';
import 'package:hiddify/singbox/model/singbox_config_enum.dart';
import 'package:hiddify/singbox/model/singbox_config_option.dart';
import 'package:hiddify/singbox/model/singbox_rule.dart';
import 'package:hiddify/utils/platform_utils.dart';
part 'config_option_entity.freezed.dart';
part 'config_option_entity.g.dart';
@freezed
class ConfigOptionEntity with _$ConfigOptionEntity {
const ConfigOptionEntity._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory ConfigOptionEntity({
required ServiceMode serviceMode,
@Default(LogLevel.warn) LogLevel logLevel,
@Default(false) bool resolveDestination,
@Default(IPv6Mode.disable) IPv6Mode ipv6Mode,
@Default("udp://1.1.1.1") String remoteDnsAddress,
@Default(DomainStrategy.auto) DomainStrategy remoteDnsDomainStrategy,
@Default("1.1.1.1") String directDnsAddress,
@Default(DomainStrategy.auto) DomainStrategy directDnsDomainStrategy,
@Default(2334) int mixedPort,
@Default(6450) int localDnsPort,
@Default(TunImplementation.mixed) TunImplementation tunImplementation,
@Default(9000) int mtu,
@Default(true) bool strictRoute,
@Default("http://cp.cloudflare.com/") String connectionTestUrl,
@IntervalInSecondsConverter()
@Default(Duration(minutes: 10))
Duration urlTestInterval,
@Default(true) bool enableClashApi,
@Default(6756) int clashApiPort,
@Default(false) bool bypassLan,
@Default(false) bool allowConnectionFromLan,
@Default(false) bool enableFakeDns,
@Default(true) bool enableDnsRouting,
@Default(true) bool independentDnsCache,
@Default(false) bool enableTlsFragment,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 1, max: 500))
OptionalRange tlsFragmentSize,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 0, max: 500))
OptionalRange tlsFragmentSleep,
@Default(false) bool enableTlsMixedSniCase,
@Default(false) bool enableTlsPadding,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 1, max: 1500))
OptionalRange tlsPaddingSize,
@Default(false) bool enableMux,
@Default(false) bool muxPadding,
@Default(8) int muxMaxStreams,
@Default(MuxProtocol.h2mux) MuxProtocol muxProtocol,
@Default(false) bool enableWarp,
@Default(WarpDetourMode.outbound) WarpDetourMode warpDetourMode,
@Default("") String warpLicenseKey,
@Default("") String warpAccountId,
@Default("") String warpAccessToken,
@Default("auto") String warpCleanIp,
@Default(0) int warpPort,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 5, max: 10))
OptionalRange warpNoise,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 20, max: 200))
OptionalRange warpNoiseDelay,
@Default("") String warpWireguardConfig,
}) = _ConfigOptionEntity;
factory ConfigOptionEntity.initial() => ConfigOptionEntity(
serviceMode: ServiceMode.defaultMode,
);
bool hasExperimentalOptions() {
if (PlatformUtils.isDesktop && serviceMode == ServiceMode.tun) {
return true;
}
if (enableTlsFragment ||
enableTlsMixedSniCase ||
enableTlsPadding ||
enableMux ||
enableWarp) {
return true;
}
return false;
}
String format() {
const encoder = JsonEncoder.withIndent(' ');
return encoder.convert(toJson());
}
ConfigOptionEntity patch(ConfigOptionPatch patch) {
return copyWith(
serviceMode: patch.serviceMode ?? serviceMode,
logLevel: patch.logLevel ?? logLevel,
resolveDestination: patch.resolveDestination ?? resolveDestination,
ipv6Mode: patch.ipv6Mode ?? ipv6Mode,
remoteDnsAddress: patch.remoteDnsAddress ?? remoteDnsAddress,
remoteDnsDomainStrategy:
patch.remoteDnsDomainStrategy ?? remoteDnsDomainStrategy,
directDnsAddress: patch.directDnsAddress ?? directDnsAddress,
directDnsDomainStrategy:
patch.directDnsDomainStrategy ?? directDnsDomainStrategy,
mixedPort: patch.mixedPort ?? mixedPort,
localDnsPort: patch.localDnsPort ?? localDnsPort,
tunImplementation: patch.tunImplementation ?? tunImplementation,
mtu: patch.mtu ?? mtu,
strictRoute: patch.strictRoute ?? strictRoute,
connectionTestUrl: patch.connectionTestUrl ?? connectionTestUrl,
urlTestInterval: patch.urlTestInterval ?? urlTestInterval,
enableClashApi: patch.enableClashApi ?? enableClashApi,
clashApiPort: patch.clashApiPort ?? clashApiPort,
bypassLan: patch.bypassLan ?? bypassLan,
allowConnectionFromLan:
patch.allowConnectionFromLan ?? allowConnectionFromLan,
enableFakeDns: patch.enableFakeDns ?? enableFakeDns,
enableDnsRouting: patch.enableDnsRouting ?? enableDnsRouting,
independentDnsCache: patch.independentDnsCache ?? independentDnsCache,
enableTlsFragment: patch.enableTlsFragment ?? enableTlsFragment,
tlsFragmentSize: patch.tlsFragmentSize ?? tlsFragmentSize,
tlsFragmentSleep: patch.tlsFragmentSleep ?? tlsFragmentSleep,
enableTlsMixedSniCase:
patch.enableTlsMixedSniCase ?? enableTlsMixedSniCase,
enableTlsPadding: patch.enableTlsPadding ?? enableTlsPadding,
tlsPaddingSize: patch.tlsPaddingSize ?? tlsPaddingSize,
enableMux: patch.enableMux ?? enableMux,
muxPadding: patch.muxPadding ?? muxPadding,
muxMaxStreams: patch.muxMaxStreams ?? muxMaxStreams,
muxProtocol: patch.muxProtocol ?? muxProtocol,
enableWarp: patch.enableWarp ?? enableWarp,
warpDetourMode: patch.warpDetourMode ?? warpDetourMode,
warpLicenseKey: patch.warpLicenseKey ?? warpLicenseKey,
warpAccountId: patch.warpAccountId ?? warpAccountId,
warpAccessToken: patch.warpAccessToken ?? warpAccessToken,
warpCleanIp: patch.warpCleanIp ?? warpCleanIp,
warpPort: patch.warpPort ?? warpPort,
warpNoise: patch.warpNoise ?? warpNoise,
warpNoiseDelay: patch.warpNoiseDelay ?? warpNoiseDelay,
warpWireguardConfig: patch.warpWireguardConfig ?? warpWireguardConfig,
);
}
SingboxConfigOption toSingbox({
required String geoipPath,
required String geositePath,
required List<SingboxRule> rules,
}) {
return SingboxConfigOption(
executeConfigAsIs: false,
logLevel: logLevel,
resolveDestination: resolveDestination,
ipv6Mode: ipv6Mode,
remoteDnsAddress: remoteDnsAddress,
remoteDnsDomainStrategy: remoteDnsDomainStrategy,
directDnsAddress: directDnsAddress,
directDnsDomainStrategy: directDnsDomainStrategy,
mixedPort: mixedPort,
localDnsPort: localDnsPort,
tunImplementation: tunImplementation,
mtu: mtu,
strictRoute: strictRoute,
connectionTestUrl: connectionTestUrl,
urlTestInterval: urlTestInterval,
enableClashApi: enableClashApi,
clashApiPort: clashApiPort,
enableTun: serviceMode == ServiceMode.tun,
enableTunService: serviceMode == ServiceMode.tunService,
setSystemProxy: serviceMode == ServiceMode.systemProxy,
bypassLan: bypassLan,
allowConnectionFromLan: allowConnectionFromLan,
enableFakeDns: enableFakeDns,
enableDnsRouting: enableDnsRouting,
independentDnsCache: independentDnsCache,
enableTlsFragment: enableTlsFragment,
tlsFragmentSize: tlsFragmentSize,
tlsFragmentSleep: tlsFragmentSleep,
enableTlsMixedSniCase: enableTlsMixedSniCase,
enableTlsPadding: enableTlsPadding,
tlsPaddingSize: tlsPaddingSize,
enableMux: enableMux,
muxPadding: muxPadding,
muxMaxStreams: muxMaxStreams,
muxProtocol: muxProtocol,
geoipPath: geoipPath,
geositePath: geositePath,
rules: rules,
warp: SingboxWarpOption(
enable: enableWarp,
mode: warpDetourMode,
licenseKey: warpLicenseKey,
accountId: warpAccountId,
accessToken: warpAccessToken,
cleanIp: warpCleanIp,
cleanPort: warpPort,
warpNoise: warpNoise,
warpNoiseDelay: warpNoiseDelay,
wireguardConfig: warpWireguardConfig,
),
);
}
factory ConfigOptionEntity.fromJson(Map<String, dynamic> json) =>
_$ConfigOptionEntityFromJson(json);
}
@freezed
class ConfigOptionPatch with _$ConfigOptionPatch {
const ConfigOptionPatch._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory ConfigOptionPatch({
ServiceMode? serviceMode,
LogLevel? logLevel,
bool? resolveDestination,
IPv6Mode? ipv6Mode,
String? remoteDnsAddress,
DomainStrategy? remoteDnsDomainStrategy,
String? directDnsAddress,
DomainStrategy? directDnsDomainStrategy,
int? mixedPort,
int? localDnsPort,
TunImplementation? tunImplementation,
int? mtu,
bool? strictRoute,
String? connectionTestUrl,
@IntervalInSecondsConverter() Duration? urlTestInterval,
bool? enableClashApi,
int? clashApiPort,
bool? bypassLan,
bool? allowConnectionFromLan,
bool? enableFakeDns,
bool? enableDnsRouting,
bool? independentDnsCache,
bool? enableTlsFragment,
@OptionalRangeJsonConverter() OptionalRange? tlsFragmentSize,
@OptionalRangeJsonConverter() OptionalRange? tlsFragmentSleep,
bool? enableTlsMixedSniCase,
bool? enableTlsPadding,
@OptionalRangeJsonConverter() OptionalRange? tlsPaddingSize,
bool? enableMux,
bool? muxPadding,
int? muxMaxStreams,
MuxProtocol? muxProtocol,
bool? enableWarp,
WarpDetourMode? warpDetourMode,
String? warpLicenseKey,
String? warpAccountId,
String? warpAccessToken,
String? warpCleanIp,
int? warpPort,
@OptionalRangeJsonConverter() OptionalRange? warpNoise,
@OptionalRangeJsonConverter() OptionalRange? warpNoiseDelay,
String? warpWireguardConfig,
}) = _ConfigOptionPatch;
factory ConfigOptionPatch.fromJson(Map<String, dynamic> json) =>
_$ConfigOptionPatchFromJson(json);
}

View File

@@ -1,5 +1,7 @@
import 'package:hiddify/features/config_option/data/config_option_data_providers.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -8,28 +10,22 @@ part 'config_option_notifier.g.dart';
@Riverpod(keepAlive: true)
class ConfigOptionNotifier extends _$ConfigOptionNotifier with AppLogger {
@override
Future<ConfigOptionEntity> build() async {
return ref
.watch(configOptionRepositoryProvider)
.getConfigOption()
.getOrElse((l) {
loggy.error("error getting persisted options $l", l);
throw l;
});
}
Future<void> build() async {}
Future<void> updateOption(ConfigOptionPatch patch) async {
if (state case AsyncData(value: final options)) {
await ref
.read(configOptionRepositoryProvider)
.updateConfigOption(patch)
.map((_) => state = AsyncData(options.patch(patch)))
.run();
}
Future<void> exportJsonToClipboard() async {
final map = {
for (final option in ConfigOptions.preferences)
ref.read(option.notifier).entry.key: ref.read(option.notifier).raw(),
};
const encoder = JsonEncoder.withIndent(' ');
final json = encoder.convert(map);
await Clipboard.setData(ClipboardData(text: json));
}
Future<void> resetOption() async {
await ref.read(configOptionRepositoryProvider).resetConfigOption().run();
for (final option in ConfigOptions.preferences) {
await ref.read(option.notifier).reset();
}
ref.invalidateSelf();
}
}

View File

@@ -1,7 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/features/config_option/data/config_option_data_providers.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/config_option/model/config_option_failure.dart';
import 'package:hiddify/singbox/service/singbox_service_provider.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -46,15 +47,28 @@ class WarpOptionNotifier extends _$WarpOptionNotifier with AppLogger {
Future<void> generateWarpConfig() async {
if (state.configGeneration.isLoading) return;
state = state.copyWith(configGeneration: const AsyncLoading());
final result = await AsyncValue.guard(
() async => await ref
.read(configOptionRepositoryProvider)
.generateWarpConfig()
.getOrElse((l) {
loggy.warning("error generating warp config: $l", l);
throw l;
}).run(),
);
final result = await AsyncValue.guard(() async {
final warp = await ref
.read(singboxServiceProvider)
.generateWarpConfig(
licenseKey: ref.read(ConfigOptions.warpLicenseKey),
previousAccountId: ref.read(ConfigOptions.warpAccountId),
previousAccessToken: ref.read(ConfigOptions.warpAccessToken),
)
.getOrElse((l) => throw l)
.run();
await ref
.read(ConfigOptions.warpAccountId.notifier)
.update(warp.accountId);
await ref
.read(ConfigOptions.warpAccessToken.notifier)
.update(warp.accessToken);
return warp.log;
});
state = state.copyWith(configGeneration: result);
}

View File

@@ -1,17 +1,15 @@
import 'package:dartx/dartx.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/model/failures.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/widget/adaptive_icon.dart';
import 'package:hiddify/core/widget/tip_card.dart';
import 'package:hiddify/features/common/nested_app_bar.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart';
import 'package:hiddify/features/config_option/overview/warp_options_widgets.dart';
import 'package:hiddify/features/config_option/widget/preference_tile.dart';
import 'package:hiddify/features/log/model/log_level.dart';
import 'package:hiddify/features/settings/widgets/sections_widgets.dart';
import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart';
@@ -27,13 +25,6 @@ class ConfigOptionsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final defaultOptions = ConfigOptionEntity.initial();
final asyncOptions = ref.watch(configOptionNotifierProvider);
Future<void> changeOption(ConfigOptionPatch patch) async {
await ref.read(configOptionNotifierProvider.notifier).updateOption(patch);
}
String experimental(String txt) {
return "$txt (${t.settings.experimental})";
}
@@ -44,462 +35,259 @@ class ConfigOptionsPage extends HookConsumerWidget {
NestedAppBar(
title: Text(t.settings.config.pageTitle),
actions: [
if (asyncOptions case AsyncData(value: final options))
PopupMenuButton(
icon: Icon(AdaptiveIcon(context).more),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(t.general.addToClipboard),
onTap: () {
Clipboard.setData(
ClipboardData(text: options.format()),
);
},
),
PopupMenuItem(
child: Text(t.settings.config.resetBtn),
onTap: () async {
await ref
.read(configOptionNotifierProvider.notifier)
.resetOption();
},
),
];
},
),
],
),
switch (asyncOptions) {
AsyncData(value: final options) => SliverList.list(
children: [
TipCard(message: t.settings.experimentalMsg),
ListTile(
title: Text(t.settings.config.logLevel),
subtitle: Text(options.logLevel.name.toUpperCase()),
onTap: () async {
final logLevel = await SettingsPickerDialog(
title: t.settings.config.logLevel,
selected: options.logLevel,
options: LogLevel.choices,
getTitle: (e) => e.name.toUpperCase(),
resetValue: defaultOptions.logLevel,
).show(context);
if (logLevel == null) return;
await changeOption(ConfigOptionPatch(logLevel: logLevel));
},
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.route),
SwitchListTile(
title: Text(experimental(t.settings.config.bypassLan)),
value: options.bypassLan,
onChanged: (value) async =>
changeOption(ConfigOptionPatch(bypassLan: value)),
),
SwitchListTile(
title: Text(t.settings.config.resolveDestination),
value: options.resolveDestination,
onChanged: (value) async => changeOption(
ConfigOptionPatch(resolveDestination: value),
PopupMenuButton(
icon: Icon(AdaptiveIcon(context).more),
itemBuilder: (context) {
return [
PopupMenuItem(
onTap: ref
.read(configOptionNotifierProvider.notifier)
.exportJsonToClipboard,
child: Text(t.general.addToClipboard),
),
),
ListTile(
title: Text(t.settings.config.ipv6Mode),
subtitle: Text(options.ipv6Mode.present(t)),
onTap: () async {
final ipv6Mode = await SettingsPickerDialog(
title: t.settings.config.ipv6Mode,
selected: options.ipv6Mode,
options: IPv6Mode.values,
getTitle: (e) => e.present(t),
resetValue: defaultOptions.ipv6Mode,
).show(context);
if (ipv6Mode == null) return;
await changeOption(ConfigOptionPatch(ipv6Mode: ipv6Mode));
},
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.dns),
ListTile(
title: Text(t.settings.config.remoteDnsAddress),
subtitle: Text(options.remoteDnsAddress),
onTap: () async {
final url = await SettingsInputDialog(
title: t.settings.config.remoteDnsAddress,
initialValue: options.remoteDnsAddress,
resetValue: defaultOptions.remoteDnsAddress,
).show(context);
if (url == null || url.isEmpty) return;
await changeOption(
ConfigOptionPatch(remoteDnsAddress: url),
);
},
),
ListTile(
title: Text(t.settings.config.remoteDnsDomainStrategy),
subtitle: Text(options.remoteDnsDomainStrategy.displayName),
onTap: () async {
final domainStrategy = await SettingsPickerDialog(
title: t.settings.config.remoteDnsDomainStrategy,
selected: options.remoteDnsDomainStrategy,
options: DomainStrategy.values,
getTitle: (e) => e.displayName,
resetValue: defaultOptions.remoteDnsDomainStrategy,
).show(context);
if (domainStrategy == null) return;
await changeOption(
ConfigOptionPatch(
remoteDnsDomainStrategy: domainStrategy,
),
);
},
),
ListTile(
title: Text(t.settings.config.directDnsAddress),
subtitle: Text(options.directDnsAddress),
onTap: () async {
final url = await SettingsInputDialog(
title: t.settings.config.directDnsAddress,
initialValue: options.directDnsAddress,
resetValue: defaultOptions.directDnsAddress,
).show(context);
if (url == null || url.isEmpty) return;
await changeOption(
ConfigOptionPatch(directDnsAddress: url),
);
},
),
ListTile(
title: Text(t.settings.config.directDnsDomainStrategy),
subtitle: Text(options.directDnsDomainStrategy.displayName),
onTap: () async {
final domainStrategy = await SettingsPickerDialog(
title: t.settings.config.directDnsDomainStrategy,
selected: options.directDnsDomainStrategy,
options: DomainStrategy.values,
getTitle: (e) => e.displayName,
resetValue: defaultOptions.directDnsDomainStrategy,
).show(context);
if (domainStrategy == null) return;
await changeOption(
ConfigOptionPatch(
directDnsDomainStrategy: domainStrategy,
),
);
},
),
SwitchListTile(
title: Text(t.settings.config.enableDnsRouting),
value: options.enableDnsRouting,
onChanged: (value) => changeOption(
ConfigOptionPatch(enableDnsRouting: value),
),
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.mux)),
SwitchListTile(
title: Text(t.settings.config.enableMux),
value: options.enableMux,
onChanged: (value) => changeOption(
ConfigOptionPatch(enableMux: value),
),
),
ListTile(
title: Text(t.settings.config.muxProtocol),
subtitle: Text(options.muxProtocol.name),
onTap: () async {
final pickedProtocol = await SettingsPickerDialog(
title: t.settings.config.muxProtocol,
selected: options.muxProtocol,
options: MuxProtocol.values,
getTitle: (e) => e.name,
resetValue: defaultOptions.muxProtocol,
).show(context);
if (pickedProtocol == null) return;
await changeOption(
ConfigOptionPatch(muxProtocol: pickedProtocol),
);
},
),
ListTile(
title: Text(t.settings.config.muxMaxStreams),
subtitle: Text(options.muxMaxStreams.toString()),
onTap: () async {
final maxStreams = await SettingsInputDialog(
title: t.settings.config.muxMaxStreams,
initialValue: options.muxMaxStreams,
resetValue: defaultOptions.muxMaxStreams,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (maxStreams == null || maxStreams < 1) return;
await changeOption(
ConfigOptionPatch(muxMaxStreams: maxStreams),
);
},
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.inbound),
ListTile(
title: Text(t.settings.config.serviceMode),
subtitle: Text(options.serviceMode.present(t)),
onTap: () async {
final pickedMode = await SettingsPickerDialog(
title: t.settings.config.serviceMode,
selected: options.serviceMode,
options: ServiceMode.choices,
getTitle: (e) => e.present(t),
resetValue: ServiceMode.defaultMode,
).show(context);
if (pickedMode == null) return;
await changeOption(
ConfigOptionPatch(serviceMode: pickedMode),
);
},
),
SwitchListTile(
title: Text(t.settings.config.strictRoute),
value: options.strictRoute,
onChanged: (value) async =>
changeOption(ConfigOptionPatch(strictRoute: value)),
),
ListTile(
title: Text(t.settings.config.tunImplementation),
subtitle: Text(options.tunImplementation.name),
onTap: () async {
final tunImplementation = await SettingsPickerDialog(
title: t.settings.config.tunImplementation,
selected: options.tunImplementation,
options: TunImplementation.values,
getTitle: (e) => e.name,
resetValue: defaultOptions.tunImplementation,
).show(context);
if (tunImplementation == null) return;
await changeOption(
ConfigOptionPatch(tunImplementation: tunImplementation),
);
},
),
ListTile(
title: Text(t.settings.config.mixedPort),
subtitle: Text(options.mixedPort.toString()),
onTap: () async {
final mixedPort = await SettingsInputDialog(
title: t.settings.config.mixedPort,
initialValue: options.mixedPort,
resetValue: defaultOptions.mixedPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (mixedPort == null) return;
await changeOption(
ConfigOptionPatch(mixedPort: mixedPort),
);
},
),
ListTile(
title: Text(t.settings.config.localDnsPort),
subtitle: Text(options.localDnsPort.toString()),
onTap: () async {
final localDnsPort = await SettingsInputDialog(
title: t.settings.config.localDnsPort,
initialValue: options.localDnsPort,
resetValue: defaultOptions.localDnsPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (localDnsPort == null) return;
await changeOption(
ConfigOptionPatch(localDnsPort: localDnsPort),
);
},
),
SwitchListTile(
title: Text(
experimental(t.settings.config.allowConnectionFromLan),
),
value: options.allowConnectionFromLan,
onChanged: (value) => changeOption(
ConfigOptionPatch(allowConnectionFromLan: value),
),
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.tlsTricks),
SwitchListTile(
title:
Text(experimental(t.settings.config.enableTlsFragment)),
value: options.enableTlsFragment,
onChanged: (value) async => changeOption(
ConfigOptionPatch(enableTlsFragment: value),
),
),
ListTile(
title: Text(t.settings.config.tlsFragmentSize),
subtitle: Text(options.tlsFragmentSize.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsFragmentSize,
initialValue: options.tlsFragmentSize.format(),
resetValue: defaultOptions.tlsFragmentSize.format(),
).show(context);
if (range == null) return;
await changeOption(
ConfigOptionPatch(
tlsFragmentSize: OptionalRange.tryParse(range),
),
);
},
),
ListTile(
title: Text(t.settings.config.tlsFragmentSleep),
subtitle: Text(options.tlsFragmentSleep.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsFragmentSleep,
initialValue: options.tlsFragmentSleep.format(),
resetValue: defaultOptions.tlsFragmentSleep.format(),
).show(context);
if (range == null) return;
await changeOption(
ConfigOptionPatch(
tlsFragmentSleep: OptionalRange.tryParse(range),
),
);
},
),
SwitchListTile(
title: Text(
experimental(t.settings.config.enableTlsMixedSniCase),
),
value: options.enableTlsMixedSniCase,
onChanged: (value) async => changeOption(
ConfigOptionPatch(enableTlsMixedSniCase: value),
),
),
SwitchListTile(
title:
Text(experimental(t.settings.config.enableTlsPadding)),
value: options.enableTlsPadding,
onChanged: (value) async => changeOption(
ConfigOptionPatch(enableTlsPadding: value),
),
),
ListTile(
title: Text(t.settings.config.tlsPaddingSize),
subtitle: Text(options.tlsPaddingSize.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsPaddingSize,
initialValue: options.tlsPaddingSize.format(),
resetValue: defaultOptions.tlsPaddingSize.format(),
).show(context);
if (range == null) return;
await changeOption(
ConfigOptionPatch(
tlsPaddingSize: OptionalRange.tryParse(range),
),
);
},
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.warp)),
WarpOptionsTiles(
options: options,
defaultOptions: defaultOptions,
onChange: changeOption,
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.misc),
ListTile(
title: Text(t.settings.config.connectionTestUrl),
subtitle: Text(options.connectionTestUrl),
onTap: () async {
final url = await SettingsInputDialog(
title: t.settings.config.connectionTestUrl,
initialValue: options.connectionTestUrl,
resetValue: defaultOptions.connectionTestUrl,
).show(context);
if (url == null || url.isEmpty || !isUrl(url)) return;
await changeOption(
ConfigOptionPatch(connectionTestUrl: url),
);
},
),
ListTile(
title: Text(t.settings.config.urlTestInterval),
subtitle: Text(
options.urlTestInterval
.toApproximateTime(isRelativeToNow: false),
),
onTap: () async {
final urlTestInterval = await SettingsSliderDialog(
title: t.settings.config.urlTestInterval,
initialValue: options.urlTestInterval.inMinutes
.coerceIn(0, 60)
.toDouble(),
resetValue:
defaultOptions.urlTestInterval.inMinutes.toDouble(),
min: 1,
max: 60,
divisions: 60,
labelGen: (value) => Duration(minutes: value.toInt())
.toApproximateTime(isRelativeToNow: false),
).show(context);
if (urlTestInterval == null) return;
await changeOption(
ConfigOptionPatch(
urlTestInterval:
Duration(minutes: urlTestInterval.toInt()),
),
);
},
),
ListTile(
title: Text(t.settings.config.clashApiPort),
subtitle: Text(options.clashApiPort.toString()),
onTap: () async {
final clashApiPort = await SettingsInputDialog(
title: t.settings.config.clashApiPort,
initialValue: options.clashApiPort,
resetValue: defaultOptions.clashApiPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (clashApiPort == null) return;
await changeOption(
ConfigOptionPatch(clashApiPort: clashApiPort),
);
},
),
const Gap(24),
],
),
AsyncError(:final error) => SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(FluentIcons.error_circle_24_regular),
const Gap(2),
Text(t.presentShortError(error)),
const Gap(2),
TextButton(
onPressed: () async {
PopupMenuItem(
child: Text(t.settings.config.resetBtn),
onTap: () async {
await ref
.read(configOptionNotifierProvider.notifier)
.resetOption();
},
child: Text(t.settings.config.resetBtn),
),
],
),
];
},
),
_ => const SliverToBoxAdapter(),
},
],
),
SliverList.list(
children: [
TipCard(message: t.settings.experimentalMsg),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.logLevel),
preferences: ref.watch(ConfigOptions.logLevel.notifier),
choices: LogLevel.choices,
title: t.settings.config.logLevel,
presentChoice: (value) => value.name.toUpperCase(),
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.route),
SwitchListTile(
title: Text(experimental(t.settings.config.bypassLan)),
value: ref.watch(ConfigOptions.bypassLan),
onChanged: ref.watch(ConfigOptions.bypassLan.notifier).update,
),
SwitchListTile(
title: Text(t.settings.config.resolveDestination),
value: ref.watch(ConfigOptions.resolveDestination),
onChanged:
ref.watch(ConfigOptions.resolveDestination.notifier).update,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.ipv6Mode),
preferences: ref.watch(ConfigOptions.ipv6Mode.notifier),
choices: IPv6Mode.values,
title: t.settings.config.ipv6Mode,
presentChoice: (value) => value.present(t),
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.dns),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.remoteDnsAddress),
preferences: ref.watch(ConfigOptions.remoteDnsAddress.notifier),
title: t.settings.config.remoteDnsAddress,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.remoteDnsDomainStrategy),
preferences:
ref.watch(ConfigOptions.remoteDnsDomainStrategy.notifier),
choices: DomainStrategy.values,
title: t.settings.config.remoteDnsDomainStrategy,
presentChoice: (value) => value.displayName,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.directDnsAddress),
preferences: ref.watch(ConfigOptions.directDnsAddress.notifier),
title: t.settings.config.directDnsAddress,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.directDnsDomainStrategy),
preferences:
ref.watch(ConfigOptions.directDnsDomainStrategy.notifier),
choices: DomainStrategy.values,
title: t.settings.config.directDnsDomainStrategy,
presentChoice: (value) => value.displayName,
),
SwitchListTile(
title: Text(t.settings.config.enableDnsRouting),
value: ref.watch(ConfigOptions.enableDnsRouting),
onChanged:
ref.watch(ConfigOptions.enableDnsRouting.notifier).update,
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.mux)),
SwitchListTile(
title: Text(t.settings.config.enableMux),
value: ref.watch(ConfigOptions.enableMux),
onChanged: ref.watch(ConfigOptions.enableMux.notifier).update,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.muxProtocol),
preferences: ref.watch(ConfigOptions.muxProtocol.notifier),
choices: MuxProtocol.values,
title: t.settings.config.muxProtocol,
presentChoice: (value) => value.name,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.muxMaxStreams),
preferences: ref.watch(ConfigOptions.muxMaxStreams.notifier),
title: t.settings.config.muxMaxStreams,
inputToValue: int.tryParse,
digitsOnly: true,
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.inbound),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.serviceMode),
preferences: ref.watch(ConfigOptions.serviceMode.notifier),
choices: ServiceMode.choices,
title: t.settings.config.serviceMode,
presentChoice: (value) => value.present(t),
),
SwitchListTile(
title: Text(t.settings.config.strictRoute),
value: ref.watch(ConfigOptions.strictRoute),
onChanged: ref.watch(ConfigOptions.strictRoute.notifier).update,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.tunImplementation),
preferences:
ref.watch(ConfigOptions.tunImplementation.notifier),
choices: TunImplementation.values,
title: t.settings.config.tunImplementation,
presentChoice: (value) => value.name,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.mixedPort),
preferences: ref.watch(ConfigOptions.mixedPort.notifier),
title: t.settings.config.mixedPort,
inputToValue: int.tryParse,
digitsOnly: true,
validateInput: isPort,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.localDnsPort),
preferences: ref.watch(ConfigOptions.localDnsPort.notifier),
title: t.settings.config.localDnsPort,
inputToValue: int.tryParse,
digitsOnly: true,
validateInput: isPort,
),
SwitchListTile(
title: Text(
experimental(t.settings.config.allowConnectionFromLan),
),
value: ref.watch(ConfigOptions.allowConnectionFromLan),
onChanged: ref
.read(ConfigOptions.allowConnectionFromLan.notifier)
.update,
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.tlsTricks),
SwitchListTile(
title: Text(experimental(t.settings.config.enableTlsFragment)),
value: ref.watch(ConfigOptions.enableTlsFragment),
onChanged:
ref.watch(ConfigOptions.enableTlsFragment.notifier).update,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.tlsFragmentSize),
preferences: ref.watch(ConfigOptions.tlsFragmentSize.notifier),
title: t.settings.config.tlsFragmentSize,
inputToValue: OptionalRange.tryParse,
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.tlsFragmentSleep),
preferences: ref.watch(ConfigOptions.tlsFragmentSleep.notifier),
title: t.settings.config.tlsFragmentSleep,
inputToValue: OptionalRange.tryParse,
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
SwitchListTile(
title: Text(
experimental(t.settings.config.enableTlsMixedSniCase),
),
value: ref.watch(ConfigOptions.enableTlsMixedSniCase),
onChanged: ref
.watch(ConfigOptions.enableTlsMixedSniCase.notifier)
.update,
),
SwitchListTile(
title: Text(experimental(t.settings.config.enableTlsPadding)),
value: ref.watch(ConfigOptions.enableTlsPadding),
onChanged:
ref.watch(ConfigOptions.enableTlsPadding.notifier).update,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.tlsPaddingSize),
preferences: ref.watch(ConfigOptions.tlsPaddingSize.notifier),
title: t.settings.config.tlsPaddingSize,
inputToValue: OptionalRange.tryParse,
presentValue: (value) => value.format(),
formatInputValue: (value) => value.format(),
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.warp)),
const WarpOptionsTiles(),
const SettingsDivider(),
SettingsSection(t.settings.config.section.misc),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.connectionTestUrl),
preferences:
ref.watch(ConfigOptions.connectionTestUrl.notifier),
title: t.settings.config.connectionTestUrl,
),
ListTile(
title: Text(t.settings.config.urlTestInterval),
subtitle: Text(
ref
.watch(ConfigOptions.urlTestInterval)
.toApproximateTime(isRelativeToNow: false),
),
onTap: () async {
final urlTestInterval = await SettingsSliderDialog(
title: t.settings.config.urlTestInterval,
initialValue: ref
.watch(ConfigOptions.urlTestInterval)
.inMinutes
.coerceIn(0, 60)
.toDouble(),
onReset:
ref.read(ConfigOptions.urlTestInterval.notifier).reset,
min: 1,
max: 60,
divisions: 60,
labelGen: (value) => Duration(minutes: value.toInt())
.toApproximateTime(isRelativeToNow: false),
).show(context);
if (urlTestInterval == null) return;
await ref
.read(ConfigOptions.urlTestInterval.notifier)
.update(Duration(minutes: urlTestInterval.toInt()));
},
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.clashApiPort),
preferences: ref.watch(ConfigOptions.clashApiPort.notifier),
title: t.settings.config.clashApiPort,
validateInput: isPort,
digitsOnly: true,
inputToValue: int.tryParse,
),
const Gap(24),
],
),
],
),
);

View File

@@ -1,29 +1,19 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/model/constants.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/widget/custom_alert_dialog.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/config_option/notifier/warp_option_notifier.dart';
import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart';
import 'package:hiddify/features/config_option/widget/preference_tile.dart';
import 'package:hiddify/singbox/model/singbox_config_enum.dart';
import 'package:hiddify/utils/uri_utils.dart';
import 'package:hiddify/utils/validators.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class WarpOptionsTiles extends HookConsumerWidget {
const WarpOptionsTiles({
required this.options,
required this.defaultOptions,
required this.onChange,
super.key,
});
final ConfigOptionEntity options;
final ConfigOptionEntity defaultOptions;
final Future<void> Function(ConfigOptionPatch patch) onChange;
const WarpOptionsTiles({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -31,7 +21,8 @@ class WarpOptionsTiles extends HookConsumerWidget {
final warpOptions = ref.watch(warpOptionNotifierProvider);
final warpPrefaceCompleted = warpOptions.consentGiven;
final canChangeOptions = warpPrefaceCompleted && options.enableWarp;
final enableWarp = ref.watch(ConfigOptions.enableWarp);
final canChangeOptions = warpPrefaceCompleted && enableWarp;
ref.listen(
warpOptionNotifierProvider.select((value) => value.configGeneration),
@@ -49,7 +40,7 @@ class WarpOptionsTiles extends HookConsumerWidget {
children: [
SwitchListTile(
title: Text(t.settings.config.enableWarp),
value: options.enableWarp,
value: enableWarp,
onChanged: (value) async {
if (!warpPrefaceCompleted) {
final agreed = await showDialog<bool>(
@@ -58,10 +49,10 @@ class WarpOptionsTiles extends HookConsumerWidget {
);
if (agreed ?? false) {
await ref.read(warpOptionNotifierProvider.notifier).agree();
await onChange(ConfigOptionPatch(enableWarp: value));
await ref.read(ConfigOptions.enableWarp.notifier).update(value);
}
} else {
await onChange(ConfigOptionPatch(enableWarp: value));
await ref.read(ConfigOptions.enableWarp.notifier).update(value);
}
},
),
@@ -85,112 +76,56 @@ class WarpOptionsTiles extends HookConsumerWidget {
.generateWarpConfig();
},
),
ListTile(
title: Text(t.settings.config.warpDetourMode),
subtitle: Text(options.warpDetourMode.present(t)),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.warpDetourMode),
preferences: ref.watch(ConfigOptions.warpDetourMode.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpDetourMode = await SettingsPickerDialog(
title: t.settings.config.warpDetourMode,
selected: options.warpDetourMode,
options: WarpDetourMode.values,
getTitle: (e) => e.present(t),
resetValue: defaultOptions.warpDetourMode,
).show(context);
if (warpDetourMode == null) return;
await onChange(
ConfigOptionPatch(warpDetourMode: warpDetourMode),
);
},
choices: WarpDetourMode.values,
title: t.settings.config.warpDetourMode,
presentChoice: (value) => value.present(t),
),
ListTile(
title: Text(t.settings.config.warpLicenseKey),
subtitle: Text(
options.warpLicenseKey.isEmpty
? t.general.notSet
: options.warpLicenseKey,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpLicenseKey),
preferences: ref.watch(ConfigOptions.warpLicenseKey.notifier),
enabled: canChangeOptions,
onTap: () async {
final licenseKey = await SettingsInputDialog(
title: t.settings.config.warpLicenseKey,
initialValue: options.warpLicenseKey,
resetValue: defaultOptions.warpLicenseKey,
).show(context);
if (licenseKey == null) return;
await onChange(ConfigOptionPatch(warpLicenseKey: licenseKey));
},
title: t.settings.config.warpLicenseKey,
presentValue: (value) => value.isEmpty ? t.general.notSet : value,
),
ListTile(
title: Text(t.settings.config.warpCleanIp),
subtitle: Text(options.warpCleanIp),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpCleanIp),
preferences: ref.watch(ConfigOptions.warpCleanIp.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpCleanIp = await SettingsInputDialog(
title: t.settings.config.warpCleanIp,
initialValue: options.warpCleanIp,
resetValue: defaultOptions.warpCleanIp,
).show(context);
if (warpCleanIp == null || warpCleanIp.isBlank) return;
await onChange(ConfigOptionPatch(warpCleanIp: warpCleanIp));
},
title: t.settings.config.warpCleanIp,
),
ListTile(
title: Text(t.settings.config.warpPort),
subtitle: Text(options.warpPort.toString()),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpPort),
preferences: ref.watch(ConfigOptions.warpPort.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpPort = await SettingsInputDialog(
title: t.settings.config.warpPort,
initialValue: options.warpPort,
resetValue: defaultOptions.warpPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (warpPort == null) return;
await onChange(
ConfigOptionPatch(warpPort: warpPort),
);
},
title: t.settings.config.warpPort,
inputToValue: int.tryParse,
validateInput: isPort,
digitsOnly: true,
),
ListTile(
title: Text(t.settings.config.warpNoise),
subtitle: Text(options.warpNoise.present(t)),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpNoise),
preferences: ref.watch(ConfigOptions.warpNoise.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpNoise = await SettingsInputDialog(
title: t.settings.config.warpNoise,
initialValue: options.warpNoise.format(),
resetValue: defaultOptions.warpNoise.format(),
).show(context);
if (warpNoise == null) return;
await onChange(
ConfigOptionPatch(
warpNoise: OptionalRange.tryParse(warpNoise, allowEmpty: true),
),
);
},
title: t.settings.config.warpNoise,
inputToValue: (input) =>
OptionalRange.tryParse(input, allowEmpty: true),
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
ListTile(
title: Text(t.settings.config.warpNoiseDelay),
subtitle: Text(options.warpNoiseDelay.present(t)),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpNoiseDelay),
preferences: ref.watch(ConfigOptions.warpNoiseDelay.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpNoiseDelay = await SettingsInputDialog(
title: t.settings.config.warpNoiseDelay,
initialValue: options.warpNoiseDelay.format(),
resetValue: defaultOptions.warpNoiseDelay.format(),
).show(context);
if (warpNoiseDelay == null) return;
await onChange(
ConfigOptionPatch(
warpNoiseDelay:
OptionalRange.tryParse(warpNoiseDelay, allowEmpty: true),
),
);
},
)
title: t.settings.config.warpNoiseDelay,
inputToValue: (input) =>
OptionalRange.tryParse(input, allowEmpty: true),
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
],
);
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/settings/widgets/widgets.dart';
class ValuePreferenceWidget<T> extends StatelessWidget {
const ValuePreferenceWidget({
super.key,
required this.value,
required this.preferences,
this.enabled = true,
required this.title,
this.presentValue,
this.formatInputValue,
this.validateInput,
this.inputToValue,
this.digitsOnly = false,
});
final T value;
final PreferencesNotifier<T, dynamic> preferences;
final bool enabled;
final String title;
final String Function(T value)? presentValue;
final String Function(T value)? formatInputValue;
final bool Function(String value)? validateInput;
final T? Function(String input)? inputToValue;
final bool digitsOnly;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: Text(presentValue?.call(value) ?? value.toString()),
enabled: enabled,
onTap: () async {
final inputValue = await SettingsInputDialog(
title: title,
initialValue: value,
validator: validateInput,
valueFormatter: formatInputValue,
onReset: preferences.reset,
digitsOnly: digitsOnly,
mapTo: inputToValue,
).show(context);
if (inputValue == null) {
return;
}
await preferences.update(inputValue);
},
);
}
}
class ChoicePreferenceWidget<T> extends StatelessWidget {
const ChoicePreferenceWidget({
super.key,
required this.selected,
required this.preferences,
this.enabled = true,
required this.choices,
required this.title,
required this.presentChoice,
this.validateInput,
});
final T selected;
final PreferencesNotifier<T, dynamic> preferences;
final bool enabled;
final List<T> choices;
final String title;
final String Function(T value) presentChoice;
final bool Function(String value)? validateInput;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: Text(presentChoice(selected)),
enabled: enabled,
onTap: () async {
final selection = await SettingsPickerDialog(
title: title,
selected: selected,
options: choices,
getTitle: (e) => presentChoice(e),
onReset: preferences.reset,
).show(context);
if (selection == null) {
return;
}
await preferences.update(selection);
},
);
}
}

View File

@@ -15,8 +15,7 @@ ConnectionRepository connectionRepository(
) {
return ConnectionRepositoryImpl(
directories: ref.watch(appDirectoriesProvider).requireValue,
singBoxConfigOptionRepository:
ref.watch(singBoxConfigOptionRepositoryProvider),
configOptionRepository: ref.watch(configOptionRepositoryProvider),
singbox: ref.watch(singboxServiceProvider),
platformSource: ConnectionPlatformSourceImpl(),
profilePathResolver: ref.watch(profilePathResolverProvider),

View File

@@ -38,7 +38,7 @@ class ConnectionRepositoryImpl
required this.directories,
required this.singbox,
required this.platformSource,
required this.singBoxConfigOptionRepository,
required this.configOptionRepository,
required this.profilePathResolver,
required this.geoAssetPathResolver,
});
@@ -46,7 +46,7 @@ class ConnectionRepositoryImpl
final Directories directories;
final SingboxService singbox;
final ConnectionPlatformSource platformSource;
final SingBoxConfigOptionRepository singBoxConfigOptionRepository;
final ConfigOptionRepository configOptionRepository;
final ProfilePathResolver profilePathResolver;
final GeoAssetPathResolver geoAssetPathResolver;
@@ -83,7 +83,7 @@ class ConnectionRepositoryImpl
return TaskEither<ConnectionFailure, SingboxConfigOption>.Do(
($) async {
final options = await $(
singBoxConfigOptionRepository
configOptionRepository
.getFullSingboxConfigOption()
.mapLeft((l) => const InvalidConfigOption()),
);

View File

@@ -2,7 +2,6 @@ import 'dart:io';
import 'package:hiddify/core/haptic/haptic_service.dart';
import 'package:hiddify/core/preferences/general_preferences.dart';
import 'package:hiddify/core/preferences/service_preferences.dart';
import 'package:hiddify/features/connection/data/connection_data_providers.dart';
import 'package:hiddify/features/connection/data/connection_repository.dart';
import 'package:hiddify/features/connection/model/connection_status.dart';
@@ -49,7 +48,7 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
yield* _connectionRepo.watchConnectionStatus().doOnData((event) {
if (event case Disconnected(connectionFailure: final _?)
when PlatformUtils.isDesktop) {
ref.read(startedByUserProvider.notifier).update(false);
ref.read(Preferences.startedByUser.notifier).update(false);
}
loggy.info("connection status: ${event.format()}");
});
@@ -73,11 +72,11 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
switch (value) {
case Disconnected():
await haptic.lightImpact();
await ref.read(startedByUserProvider.notifier).update(true);
await ref.read(Preferences.startedByUser.notifier).update(true);
await _connect();
case Connected():
await haptic.mediumImpact();
await ref.read(startedByUserProvider.notifier).update(false);
await ref.read(Preferences.startedByUser.notifier).update(false);
await _disconnect();
default:
loggy.warning("switching status, debounce");
@@ -92,12 +91,12 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
return _disconnect();
}
loggy.info("active profile changed, reconnecting");
await ref.read(startedByUserProvider.notifier).update(true);
await ref.read(Preferences.startedByUser.notifier).update(true);
await _connectionRepo
.reconnect(
profile.id,
profile.name,
ref.read(disableMemoryLimitProvider),
ref.read(Preferences.disableMemoryLimit),
)
.mapLeft((err) {
loggy.warning("error reconnecting", err);
@@ -127,7 +126,7 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
.connect(
activeProfile.id,
activeProfile.name,
ref.read(disableMemoryLimitProvider),
ref.read(Preferences.disableMemoryLimit),
)
.mapLeft((err) async {
loggy.warning("error connecting", err);
@@ -136,7 +135,7 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
if (err.toString().contains("panic")) {
await Sentry.captureException(Exception(err.toString()));
}
await ref.read(startedByUserProvider.notifier).update(false);
await ref.read(Preferences.startedByUser.notifier).update(false);
state = AsyncError(err, StackTrace.current);
}).run();
}

View File

@@ -4,7 +4,7 @@ import 'package:gap/gap.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/model/failures.dart';
import 'package:hiddify/core/theme/theme_extensions.dart';
import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/connection/model/connection_status.dart';
import 'package:hiddify/features/connection/notifier/connection_notifier.dart';
import 'package:hiddify/features/connection/widget/experimental_feature_notice.dart';
@@ -48,10 +48,7 @@ class ConnectionButton extends HookConsumerWidget {
var canConnect = true;
if (status case Disconnected()) {
final hasExperimental =
await ref.read(configOptionNotifierProvider.future).then(
(value) => value.hasExperimentalOptions(),
onError: (_) => false,
);
ref.read(ConfigOptions.hasExperimentalFeatures);
final canShowNotice =
!ref.read(disableExperimentalFeatureNoticeProvider);

View File

@@ -1,12 +1,9 @@
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:hiddify/core/http_client/dio_http_client.dart';
import 'package:timezone_to_country/timezone_to_country.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hiddify/core/analytics/analytics_controller.dart';
import 'package:hiddify/core/http_client/dio_http_client.dart';
import 'package:hiddify/core/localization/locale_preferences.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/model/constants.dart';
@@ -16,12 +13,14 @@ import 'package:hiddify/features/common/general_pref_tiles.dart';
import 'package:hiddify/gen/assets.gen.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:timezone_to_country/timezone_to_country.dart';
class IntroPage extends HookConsumerWidget with PresLogger {
bool locationInfoLoaded = false;
IntroPage({super.key});
bool locationInfoLoaded = false;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
@@ -101,7 +100,7 @@ class IntroPage extends HookConsumerWidget with PresLogger {
}
}
await ref
.read(introCompletedProvider.notifier)
.read(Preferences.introCompleted.notifier)
.update(true);
},
child: isStarting.value
@@ -128,9 +127,7 @@ class IntroPage extends HookConsumerWidget with PresLogger {
loggy.debug(
'Timezone Region: ${regionLocale.region} Locale: ${regionLocale.locale}',
);
await ref
.read(regionNotifierProvider.notifier)
.update(regionLocale.region);
await ref.read(Preferences.region.notifier).update(regionLocale.region);
await ref
.read(localePreferencesProvider.notifier)
.changeLocale(regionLocale.locale);
@@ -144,10 +141,11 @@ class IntroPage extends HookConsumerWidget with PresLogger {
try {
final DioHttpClient client = DioHttpClient(
timeout: const Duration(seconds: 2),
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
debug: true);
timeout: const Duration(seconds: 2),
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
debug: true,
);
final response =
await client.get<Map<String, dynamic>>('https://api.ip.sb/geoip/');
@@ -159,9 +157,7 @@ class IntroPage extends HookConsumerWidget with PresLogger {
loggy.debug(
'Region: ${regionLocale.region} Locale: ${regionLocale.locale}',
);
await ref
.read(regionNotifierProvider.notifier)
.update(regionLocale.region);
await ref.read(Preferences.region.notifier).update(regionLocale.region);
await ref
.read(localePreferencesProvider.notifier)
.changeLocale(regionLocale.locale);

View File

@@ -22,7 +22,7 @@ class PerAppProxyPage extends HookConsumerWidget with PresLogger {
final localizations = MaterialLocalizations.of(context);
final asyncPackages = ref.watch(installedPackagesInfoProvider);
final perAppProxyMode = ref.watch(perAppProxyModeNotifierProvider);
final perAppProxyMode = ref.watch(Preferences.perAppProxyMode);
final perAppProxyList = ref.watch(perAppProxyListProvider);
final showSystemApps = useState(true);
@@ -130,7 +130,7 @@ class PerAppProxyPage extends HookConsumerWidget with PresLogger {
groupValue: perAppProxyMode,
onChanged: (value) async {
await ref
.read(perAppProxyModeNotifierProvider.notifier)
.read(Preferences.perAppProxyMode.notifier)
.update(e);
if (e == PerAppProxyMode.off && context.mounted) {
context.pop();

View File

@@ -58,7 +58,7 @@ class AddProfile extends _$AddProfile with AppLogger {
() async {
final activeProfile = await ref.read(activeProfileProvider.future);
final markAsActive =
activeProfile == null || ref.read(markNewProfileActiveProvider);
activeProfile == null || ref.read(Preferences.markNewProfileActive);
final TaskEither<ProfileFailure, Unit> task;
if (LinkParser.parse(rawInput) case (final link)?) {
loggy.debug("adding profile, url: [${link.url}]");

View File

@@ -36,7 +36,7 @@ class ForegroundProfilesUpdateNotifier
_scheduler = null;
});
if (ref.watch(introCompletedProvider)) {
if (ref.watch(Preferences.introCompleted)) {
loggy.debug("intro done, starting");
_scheduler?.start();
} else {

View File

@@ -33,7 +33,7 @@ class IpInfoNotifier extends _$IpInfoNotifier with AppLogger {
(_, next) => _idle = false,
);
final autoCheck = ref.watch(autoCheckIpProvider);
final autoCheck = ref.watch(Preferences.autoCheckIp);
final serviceRunning = await ref.watch(serviceRunningProvider.future);
// loggy.debug(
// "idle? [$_idle], forced? [$_forceCheck], connected? [$serviceRunning]",

View File

@@ -5,11 +5,11 @@ import 'package:dartx/dartx.dart';
import 'package:hiddify/core/haptic/haptic_service.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/connection/notifier/connection_notifier.dart';
import 'package:hiddify/features/proxy/data/proxy_data_providers.dart';
import 'package:hiddify/features/proxy/model/proxy_entity.dart';
import 'package:hiddify/features/proxy/model/proxy_failure.dart';
import 'package:hiddify/utils/pref_notifier.dart';
import 'package:hiddify/utils/riverpod_utils.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -31,24 +31,24 @@ enum ProxiesSort {
@Riverpod(keepAlive: true)
class ProxiesSortNotifier extends _$ProxiesSortNotifier with AppLogger {
late final _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
"proxies_sort_mode",
ProxiesSort.delay,
late final _pref = PreferencesEntry(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
key: "proxies_sort_mode",
defaultValue: ProxiesSort.delay,
mapFrom: ProxiesSort.values.byName,
mapTo: (value) => value.name,
);
@override
ProxiesSort build() {
final sortBy = _pref.getValue();
final sortBy = _pref.read();
loggy.info("sort proxies by: [${sortBy.name}]");
return sortBy;
}
Future<void> update(ProxiesSort value) {
state = value;
return _pref.update(value);
return _pref.write(value);
}
}

View File

@@ -18,8 +18,8 @@ class AdvancedSettingTiles extends HookConsumerWidget {
final t = ref.watch(translationsProvider);
final debug = ref.watch(debugModeNotifierProvider);
final perAppProxy = ref.watch(perAppProxyModeNotifierProvider).enabled;
final disableMemoryLimit = ref.watch(disableMemoryLimitProvider);
final perAppProxy = ref.watch(Preferences.perAppProxyMode).enabled;
final disableMemoryLimit = ref.watch(Preferences.disableMemoryLimit);
return Column(
children: [
@@ -43,7 +43,7 @@ class AdvancedSettingTiles extends HookConsumerWidget {
final newMode =
perAppProxy ? PerAppProxyMode.off : PerAppProxyMode.exclude;
await ref
.read(perAppProxyModeNotifierProvider.notifier)
.read(Preferences.perAppProxyMode.notifier)
.update(newMode);
if (!perAppProxy && context.mounted) {
await const PerAppProxyRoute().push(context);
@@ -53,7 +53,7 @@ class AdvancedSettingTiles extends HookConsumerWidget {
onTap: () async {
if (!perAppProxy) {
await ref
.read(perAppProxyModeNotifierProvider.notifier)
.read(Preferences.perAppProxyMode.notifier)
.update(PerAppProxyMode.exclude);
}
if (context.mounted) await const PerAppProxyRoute().push(context);
@@ -66,7 +66,9 @@ class AdvancedSettingTiles extends HookConsumerWidget {
value: !disableMemoryLimit,
secondary: const Icon(FluentIcons.developer_board_24_regular),
onChanged: (value) async {
await ref.read(disableMemoryLimitProvider.notifier).update(!value);
await ref
.read(Preferences.disableMemoryLimit.notifier)
.update(!value);
},
),
if (Platform.isIOS)

View File

@@ -58,17 +58,17 @@ class GeneralSettingTiles extends HookConsumerWidget {
SwitchListTile(
title: Text(t.settings.general.autoIpCheck),
secondary: const Icon(FluentIcons.globe_search_24_regular),
value: ref.watch(autoCheckIpProvider),
onChanged: ref.read(autoCheckIpProvider.notifier).update,
value: ref.watch(Preferences.autoCheckIp),
onChanged: ref.read(Preferences.autoCheckIp.notifier).update,
),
if (Platform.isAndroid) ...[
SwitchListTile(
title: Text(t.settings.general.dynamicNotification),
secondary: const Icon(FluentIcons.top_speed_24_regular),
value: ref.watch(dynamicNotificationProvider),
value: ref.watch(Preferences.dynamicNotification),
onChanged: (value) async {
await ref
.read(dynamicNotificationProvider.notifier)
.read(Preferences.dynamicNotification.notifier)
.update(value);
},
),
@@ -94,11 +94,9 @@ class GeneralSettingTiles extends HookConsumerWidget {
),
SwitchListTile(
title: Text(t.settings.general.silentStart),
value: ref.watch(silentStartNotifierProvider),
value: ref.watch(Preferences.silentStart),
onChanged: (value) async {
await ref
.read(silentStartNotifierProvider.notifier)
.update(value);
await ref.read(Preferences.silentStart.notifier).update(value);
},
),
],

View File

@@ -12,7 +12,8 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
required this.initialValue,
this.mapTo,
this.validator,
this.resetValue,
this.valueFormatter,
this.onReset,
this.optionalAction,
this.icon,
this.digitsOnly = false,
@@ -22,7 +23,8 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
final T initialValue;
final T? Function(String value)? mapTo;
final bool Function(String value)? validator;
final T? resetValue;
final String Function(T value)? valueFormatter;
final VoidCallback? onReset;
final (String text, VoidCallback)? optionalAction;
final IconData? icon;
final bool digitsOnly;
@@ -41,7 +43,7 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
final localizations = MaterialLocalizations.of(context);
final textController = useTextEditingController(
text: initialValue?.toString(),
text: valueFormatter?.call(initialValue) ?? initialValue.toString(),
);
return FocusTraversalGroup(
@@ -74,12 +76,13 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
child: Text(optionalAction!.$1.toUpperCase()),
),
),
if (resetValue != null)
if (onReset != null)
FocusTraversalOrder(
order: const NumericFocusOrder(4),
child: TextButton(
onPressed: () async {
await Navigator.of(context).maybePop(resetValue);
onReset!();
await Navigator.of(context).maybePop(null);
},
child: Text(t.general.reset.toUpperCase()),
),
@@ -123,14 +126,14 @@ class SettingsPickerDialog<T> extends HookConsumerWidget with PresLogger {
required this.selected,
required this.options,
required this.getTitle,
this.resetValue,
this.onReset,
});
final String title;
final T selected;
final List<T> options;
final String Function(T e) getTitle;
final T? resetValue;
final VoidCallback? onReset;
Future<T?> show(BuildContext context) async {
return showDialog(
@@ -162,10 +165,11 @@ class SettingsPickerDialog<T> extends HookConsumerWidget with PresLogger {
.toList(),
),
actions: [
if (resetValue != null)
if (onReset != null)
TextButton(
onPressed: () async {
await Navigator.of(context).maybePop(resetValue);
onReset!();
await Navigator.of(context).maybePop(null);
},
child: Text(t.general.reset.toUpperCase()),
),
@@ -186,7 +190,7 @@ class SettingsSliderDialog extends HookConsumerWidget with PresLogger {
super.key,
required this.title,
required this.initialValue,
this.resetValue,
this.onReset,
this.min = 0,
this.max = 1,
this.divisions,
@@ -195,7 +199,7 @@ class SettingsSliderDialog extends HookConsumerWidget with PresLogger {
final String title;
final double initialValue;
final double? resetValue;
final VoidCallback? onReset;
final double min;
final double max;
final int? divisions;
@@ -229,10 +233,11 @@ class SettingsSliderDialog extends HookConsumerWidget with PresLogger {
),
),
actions: [
if (resetValue != null)
if (onReset != null)
TextButton(
onPressed: () async {
await Navigator.of(context).maybePop(resetValue);
onReset!();
await Navigator.of(context).maybePop(null);
},
child: Text(t.general.reset.toUpperCase()),
),

View File

@@ -3,8 +3,7 @@ import 'dart:io';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/model/constants.dart';
import 'package:hiddify/core/router/router.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/connection/model/connection_status.dart';
import 'package:hiddify/features/connection/notifier/connection_notifier.dart';
import 'package:hiddify/features/window/notifier/window_notifier.dart';
@@ -36,9 +35,7 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with AppLogger {
connection = const ConnectionStatus.disconnected();
}
final serviceMode = await ref
.watch(configOptionNotifierProvider.future)
.then((value) => value.serviceMode);
final serviceMode = ref.watch(ConfigOptions.serviceMode);
final t = ref.watch(translationsProvider);
final destinations = <(String label, String location)>[
@@ -88,8 +85,8 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with AppLogger {
final newMode = ServiceMode.values.byName(menuItem.key!);
loggy.debug("switching service mode: [$newMode]");
await ref
.read(configOptionNotifierProvider.notifier)
.updateOption(ConfigOptionPatch(serviceMode: newMode));
.read(ConfigOptions.serviceMode.notifier)
.update(newMode);
},
),
),

View File

@@ -1,102 +0,0 @@
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Pref<T, P> with InfraLogger {
const Pref(
this.prefs,
this.key,
this.defaultValue, {
this.mapFrom,
this.mapTo,
});
final SharedPreferences prefs;
final String key;
final T defaultValue;
final T Function(P value)? mapFrom;
final P Function(T value)? mapTo;
/// Updates the value asynchronously.
Future<void> update(T value) async {
loggy.debug("updating preference [$key]($T) to [$value]");
Object? mapped = value;
if (mapTo != null) {
mapped = mapTo!(value);
}
try {
switch (mapped) {
case String _:
await prefs.setString(key, mapped);
case bool _:
await prefs.setBool(key, mapped);
case int _:
await prefs.setInt(key, mapped);
case double _:
await prefs.setDouble(key, mapped);
case List<String> _:
await prefs.setStringList(key, mapped);
}
} catch (e) {
loggy.warning("error updating preference[$key]: $e");
}
}
T getValue() {
try {
loggy.debug("getting persisted preference [$key]($T)");
if (mapFrom != null) {
final persisted = prefs.get(key) as P?;
if (persisted == null) return defaultValue;
return mapFrom!(persisted);
} else if (T == List<String>) {
return prefs.getStringList(key) as T? ?? defaultValue;
}
return prefs.get(key) as T? ?? defaultValue;
} catch (e) {
loggy.warning("error getting preference[$key]: $e");
return defaultValue;
}
}
}
class PrefNotifier<T, P> extends AutoDisposeNotifier<T> with InfraLogger {
PrefNotifier(
this._key,
this._defaultValue,
this._mapFrom,
this._mapTo,
);
final String _key;
final T _defaultValue;
final T Function(P value)? _mapFrom;
final P Function(T)? _mapTo;
late final Pref<T, P> _pref = Pref(
ref.watch(sharedPreferencesProvider).requireValue,
_key,
_defaultValue,
mapFrom: _mapFrom,
mapTo: _mapTo,
);
static AutoDisposeNotifierProvider<PrefNotifier<T, P>, T> provider<T, P>(
String key,
T defaultValue, {
T Function(P value)? mapFrom,
P Function(T value)? mapTo,
}) =>
AutoDisposeNotifierProvider(
() => PrefNotifier(key, defaultValue, mapFrom, mapTo),
);
Future<void> update(T value) async {
_pref.update(value);
super.state = value;
}
@override
T build() => _pref.getValue();
}