Refactor profiles
This commit is contained in:
@@ -10,9 +10,10 @@ import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/data/repository/app_repository_impl.dart';
|
||||
import 'package:hiddify/domain/environment.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/window/window_controller.dart';
|
||||
import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/system_tray/system_tray_controller.dart';
|
||||
import 'package:hiddify/services/auto_start_service.dart';
|
||||
import 'package:hiddify/services/deep_link_service.dart';
|
||||
@@ -88,6 +89,7 @@ Future<void> _lazyBootstrap(
|
||||
final filesEditor = container.read(filesEditorServiceProvider);
|
||||
await filesEditor.init();
|
||||
await container.read(geoAssetRepositoryProvider.future);
|
||||
await container.read(profileRepositoryProvider.future);
|
||||
|
||||
initLoggers(container.read, debug);
|
||||
_logger.info(container.read(appInfoProvider).format());
|
||||
|
||||
97
lib/core/notification/in_app_notification_controller.dart
Normal file
97
lib/core/notification/in_app_notification_controller.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/adaptive_root_scaffold.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:toastification/toastification.dart';
|
||||
|
||||
part 'in_app_notification_controller.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
InAppNotificationController inAppNotificationController(
|
||||
InAppNotificationControllerRef ref,
|
||||
) {
|
||||
return InAppNotificationController();
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
info,
|
||||
error,
|
||||
success,
|
||||
}
|
||||
|
||||
class InAppNotificationController with AppLogger {
|
||||
void showToast(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
NotificationType type = NotificationType.info,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
toastification.show(
|
||||
context: context,
|
||||
title: message,
|
||||
type: type._toastificationType,
|
||||
alignment: Alignment.bottomLeft,
|
||||
autoCloseDuration: duration,
|
||||
style: ToastificationStyle.fillColored,
|
||||
pauseOnHover: true,
|
||||
showProgressBar: false,
|
||||
dragToClose: true,
|
||||
closeOnClick: true,
|
||||
closeButtonShowType: CloseButtonShowType.onHover,
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorToast(String message) {
|
||||
final context = RootScaffold.stateKey.currentContext;
|
||||
if (context == null) {
|
||||
loggy.warning("context is null");
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
context,
|
||||
message,
|
||||
type: NotificationType.error,
|
||||
duration: const Duration(seconds: 5),
|
||||
);
|
||||
}
|
||||
|
||||
void showSuccessToast(String message) {
|
||||
final context = RootScaffold.stateKey.currentContext;
|
||||
if (context == null) {
|
||||
loggy.warning("context is null");
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
context,
|
||||
message,
|
||||
type: NotificationType.success,
|
||||
);
|
||||
}
|
||||
|
||||
void showInfoToast(String message) {
|
||||
final context = RootScaffold.stateKey.currentContext;
|
||||
if (context == null) {
|
||||
loggy.warning("context is null");
|
||||
return;
|
||||
}
|
||||
showToast(context, message);
|
||||
}
|
||||
|
||||
Future<void> showErrorDialog(PresentableError error) async {
|
||||
final context = RootScaffold.stateKey.currentContext;
|
||||
if (context == null) {
|
||||
loggy.warning("context is null");
|
||||
return;
|
||||
}
|
||||
CustomAlertDialog.fromErr(error).show(context);
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationTypeX on NotificationType {
|
||||
ToastificationType get _toastificationType => switch (this) {
|
||||
NotificationType.success => ToastificationType.success,
|
||||
NotificationType.error => ToastificationType.error,
|
||||
NotificationType.info => ToastificationType.info,
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/router/app_router.dart';
|
||||
import 'package:hiddify/features/home/view/view.dart';
|
||||
import 'package:hiddify/features/intro/intro_page.dart';
|
||||
import 'package:hiddify/features/profile_detail/view/view.dart';
|
||||
import 'package:hiddify/features/profiles/view/view.dart';
|
||||
import 'package:hiddify/features/profile/add/add_profile_modal.dart';
|
||||
import 'package:hiddify/features/profile/details/profile_details_page.dart';
|
||||
import 'package:hiddify/features/profile/overview/profiles_overview_page.dart';
|
||||
import 'package:hiddify/features/proxies/view/view.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
@@ -86,7 +87,8 @@ class ProfilesRoute extends GoRouteData {
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return BottomSheetPage(
|
||||
name: name,
|
||||
builder: (controller) => ProfilesModal(scrollController: controller),
|
||||
builder: (controller) =>
|
||||
ProfilesOverviewModal(scrollController: controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +105,7 @@ class NewProfileRoute extends GoRouteData {
|
||||
return const MaterialPage(
|
||||
fullscreenDialog: true,
|
||||
name: name,
|
||||
child: ProfileDetailPage("new"),
|
||||
child: ProfileDetailsPage("new"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +123,7 @@ class ProfileDetailsRoute extends GoRouteData {
|
||||
return MaterialPage(
|
||||
fullscreenDialog: true,
|
||||
name: name,
|
||||
child: ProfileDetailPage(id),
|
||||
child: ProfileDetailsPage(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
50
lib/core/widget/custom_alert_dialog.dart
Normal file
50
lib/core/widget/custom_alert_dialog.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
|
||||
class CustomAlertDialog extends StatelessWidget {
|
||||
const CustomAlertDialog({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final String message;
|
||||
|
||||
factory CustomAlertDialog.fromError(PresentableError error) =>
|
||||
CustomAlertDialog(
|
||||
title: error.message == null ? null : error.type,
|
||||
message: error.message ?? error.type,
|
||||
);
|
||||
|
||||
Future<void> show(BuildContext context) async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localizations = MaterialLocalizations.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: title != null ? Text(title!) : null,
|
||||
content: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: 468,
|
||||
child: Text(message),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(localizations.okButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import 'package:dio/dio.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/general_prefs.dart';
|
||||
import 'package:hiddify/data/api/clash_api.dart';
|
||||
import 'package:hiddify/data/local/dao/profiles_dao.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/data/repository/app_repository_impl.dart';
|
||||
import 'package:hiddify/data/repository/config_options_store.dart';
|
||||
@@ -12,9 +11,9 @@ import 'package:hiddify/data/repository/repository.dart';
|
||||
import 'package:hiddify/domain/app/app.dart';
|
||||
import 'package:hiddify/domain/constants.dart';
|
||||
import 'package:hiddify/domain/core_facade.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/domain/singbox/singbox.dart';
|
||||
import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:native_dio_adapter/native_dio_adapter.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -48,20 +47,6 @@ Dio dio(DioRef ref) {
|
||||
return dio;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfilesDao profilesDao(ProfilesDaoRef ref) => ProfilesDao(
|
||||
ref.watch(appDatabaseProvider),
|
||||
);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfilesRepository profilesRepository(ProfilesRepositoryRef ref) =>
|
||||
ProfilesRepositoryImpl(
|
||||
profilesDao: ref.watch(profilesDaoProvider),
|
||||
filesEditor: ref.watch(filesEditorServiceProvider),
|
||||
singbox: ref.watch(coreFacadeProvider),
|
||||
dio: ref.watch(dioProvider),
|
||||
);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
AppRepository appRepository(AppRepositoryRef ref) =>
|
||||
AppRepositoryImpl(ref.watch(dioProvider));
|
||||
@@ -99,6 +84,7 @@ CoreFacade coreFacade(CoreFacadeRef ref) => CoreFacadeImpl(
|
||||
ref.watch(singboxServiceProvider),
|
||||
ref.watch(filesEditorServiceProvider),
|
||||
ref.watch(geoAssetPathResolverProvider),
|
||||
ref.watch(profilePathResolverProvider),
|
||||
ref.watch(platformServicesProvider),
|
||||
ref.watch(clashApiProvider),
|
||||
ref.read(debugModeNotifierProvider),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
|
||||
extension ProfileMapper on Profile {
|
||||
ProfileEntriesCompanion toCompanion() {
|
||||
return switch (this) {
|
||||
RemoteProfile(:final url, :final options, :final subInfo) =>
|
||||
ProfileEntriesCompanion.insert(
|
||||
id: id,
|
||||
type: ProfileType.remote,
|
||||
active: active,
|
||||
name: name,
|
||||
url: Value(url),
|
||||
lastUpdate: lastUpdate,
|
||||
updateInterval: Value(options?.updateInterval),
|
||||
upload: Value(subInfo?.upload),
|
||||
download: Value(subInfo?.download),
|
||||
total: Value(subInfo?.total),
|
||||
expire: Value(subInfo?.expire),
|
||||
webPageUrl: Value(subInfo?.webPageUrl),
|
||||
supportUrl: Value(subInfo?.supportUrl),
|
||||
),
|
||||
LocalProfile() => ProfileEntriesCompanion.insert(
|
||||
id: id,
|
||||
type: ProfileType.local,
|
||||
active: active,
|
||||
name: name,
|
||||
lastUpdate: lastUpdate,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static Profile fromEntry(ProfileEntry e) {
|
||||
ProfileOptions? options;
|
||||
if (e.updateInterval != null) {
|
||||
options = ProfileOptions(updateInterval: e.updateInterval!);
|
||||
}
|
||||
|
||||
SubscriptionInfo? subInfo;
|
||||
if (e.upload != null &&
|
||||
e.download != null &&
|
||||
e.total != null &&
|
||||
e.expire != null) {
|
||||
subInfo = SubscriptionInfo(
|
||||
upload: e.upload!,
|
||||
download: e.download!,
|
||||
total: e.total!,
|
||||
expire: e.expire!,
|
||||
webPageUrl: e.webPageUrl,
|
||||
supportUrl: e.supportUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return switch (e.type) {
|
||||
ProfileType.remote => RemoteProfile(
|
||||
id: e.id,
|
||||
active: e.active,
|
||||
name: e.name,
|
||||
url: e.url!,
|
||||
lastUpdate: e.lastUpdate,
|
||||
options: options,
|
||||
subInfo: subInfo,
|
||||
),
|
||||
ProfileType.local => LocalProfile(
|
||||
id: e.id,
|
||||
active: e.active,
|
||||
name: e.name,
|
||||
lastUpdate: e.lastUpdate,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import 'package:drift/native.dart';
|
||||
import 'package:hiddify/data/local/schema_versions.dart';
|
||||
import 'package:hiddify/data/local/tables.dart';
|
||||
import 'package:hiddify/data/local/type_converters.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/geo_asset/data/geo_asset_data_mapper.dart';
|
||||
import 'package:hiddify/features/geo_asset/model/default_geo_assets.dart';
|
||||
import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/services/files_editor_service.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/type_converters.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/geo_asset/model/geo_asset_entity.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
|
||||
@DataClassName('ProfileEntry')
|
||||
class ProfileEntries extends Table {
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:hiddify/domain/core_facade.dart';
|
||||
import 'package:hiddify/domain/core_service_failure.dart';
|
||||
import 'package:hiddify/domain/singbox/singbox.dart';
|
||||
import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
|
||||
import 'package:hiddify/services/files_editor_service.dart';
|
||||
import 'package:hiddify/services/platform_services.dart';
|
||||
import 'package:hiddify/services/singbox/singbox_service.dart';
|
||||
@@ -21,6 +22,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade {
|
||||
this.singbox,
|
||||
this.filesEditor,
|
||||
this.geoAssetPathResolver,
|
||||
this.profilePathResolver,
|
||||
this.platformServices,
|
||||
this.clash,
|
||||
this.debug,
|
||||
@@ -30,6 +32,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade {
|
||||
final SingboxService singbox;
|
||||
final FilesEditorService filesEditor;
|
||||
final GeoAssetPathResolver geoAssetPathResolver;
|
||||
final ProfilePathResolver profilePathResolver;
|
||||
final PlatformServices platformServices;
|
||||
final ClashApi clash;
|
||||
final bool debug;
|
||||
@@ -115,12 +118,14 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade {
|
||||
) {
|
||||
return TaskEither<CoreServiceFailure, String>.Do(
|
||||
($) async {
|
||||
final configPath = filesEditor.configPath(fileName);
|
||||
final configFile = profilePathResolver.file(fileName);
|
||||
final options = await $(_getConfigOptions());
|
||||
await $(setup());
|
||||
await $(changeConfigOptions(options));
|
||||
return await $(
|
||||
singbox.generateConfig(configPath).mapLeft(CoreServiceFailure.other),
|
||||
singbox
|
||||
.generateConfig(configFile.path)
|
||||
.mapLeft(CoreServiceFailure.other),
|
||||
);
|
||||
},
|
||||
).handleExceptions(CoreServiceFailure.unexpected);
|
||||
@@ -133,7 +138,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade {
|
||||
) {
|
||||
return TaskEither<CoreServiceFailure, Unit>.Do(
|
||||
($) async {
|
||||
final configPath = filesEditor.configPath(fileName);
|
||||
final configFile = profilePathResolver.file(fileName);
|
||||
final options = await $(_getConfigOptions());
|
||||
loggy.info(
|
||||
"config options: ${options.format()}\nMemory Limit: ${!disableMemoryLimit}",
|
||||
@@ -155,7 +160,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade {
|
||||
await $(changeConfigOptions(options));
|
||||
return await $(
|
||||
singbox
|
||||
.start(configPath, disableMemoryLimit)
|
||||
.start(configFile.path, disableMemoryLimit)
|
||||
.mapLeft(CoreServiceFailure.start),
|
||||
);
|
||||
},
|
||||
@@ -177,12 +182,12 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade {
|
||||
) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
final configPath = filesEditor.configPath(fileName);
|
||||
final configFile = profilePathResolver.file(fileName);
|
||||
return _getConfigOptions()
|
||||
.flatMap((options) => changeConfigOptions(options))
|
||||
.andThen(
|
||||
() => singbox
|
||||
.restart(configPath, disableMemoryLimit)
|
||||
.restart(configFile.path, disableMemoryLimit)
|
||||
.mapLeft(CoreServiceFailure.start),
|
||||
)
|
||||
.run();
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export 'core_facade_impl.dart';
|
||||
export 'profiles_repository_impl.dart';
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:loggy/loggy.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'profile.freezed.dart';
|
||||
part 'profile.g.dart';
|
||||
|
||||
final _loggy = Loggy('Profile');
|
||||
|
||||
enum ProfileType { remote, local }
|
||||
|
||||
@freezed
|
||||
sealed class Profile with _$Profile {
|
||||
const Profile._();
|
||||
|
||||
const factory Profile.remote({
|
||||
required String id,
|
||||
required bool active,
|
||||
required String name,
|
||||
required String url,
|
||||
required DateTime lastUpdate,
|
||||
ProfileOptions? options,
|
||||
SubscriptionInfo? subInfo,
|
||||
}) = RemoteProfile;
|
||||
|
||||
const factory Profile.local({
|
||||
required String id,
|
||||
required bool active,
|
||||
required String name,
|
||||
required DateTime lastUpdate,
|
||||
}) = LocalProfile;
|
||||
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static RemoteProfile fromResponse(
|
||||
String url,
|
||||
Map<String, List<String>> headers,
|
||||
) {
|
||||
_loggy.debug("Profile Headers: $headers");
|
||||
|
||||
final titleHeader = headers['profile-title']?.single;
|
||||
var title = '';
|
||||
if (titleHeader != null) {
|
||||
if (titleHeader.startsWith("base64:")) {
|
||||
// TODO handle errors
|
||||
title =
|
||||
utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", "")));
|
||||
} else {
|
||||
title = titleHeader;
|
||||
}
|
||||
}
|
||||
|
||||
if (title.isEmpty) {
|
||||
final contentDisposition = headers['content-disposition']?.single;
|
||||
if (contentDisposition != null) {
|
||||
final RegExp regExp = RegExp('filename="([^"]*)"');
|
||||
final match = regExp.firstMatch(contentDisposition);
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
title = match.group(1) ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (title.isEmpty) {
|
||||
final part = url.split("#").lastOrNull;
|
||||
if (part != null) {
|
||||
title = part;
|
||||
}
|
||||
}
|
||||
if (title.isEmpty) {
|
||||
final part = url.split("/").lastOrNull;
|
||||
if (part != null) {
|
||||
final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*");
|
||||
title = part.replaceFirst(pattern, "");
|
||||
}
|
||||
}
|
||||
|
||||
final updateIntervalHeader = headers['profile-update-interval']?.single;
|
||||
ProfileOptions? options;
|
||||
if (updateIntervalHeader != null) {
|
||||
final updateInterval = Duration(hours: int.parse(updateIntervalHeader));
|
||||
options = ProfileOptions(updateInterval: updateInterval);
|
||||
}
|
||||
|
||||
final subscriptionInfoHeader = headers['subscription-userinfo']?.single;
|
||||
SubscriptionInfo? subInfo;
|
||||
if (subscriptionInfoHeader != null) {
|
||||
subInfo = SubscriptionInfo.fromResponseHeader(subscriptionInfoHeader);
|
||||
}
|
||||
|
||||
final webPageUrlHeader = headers['profile-web-page-url']?.single;
|
||||
final supportUrlHeader = headers['support-url']?.single;
|
||||
if (subInfo != null) {
|
||||
subInfo = subInfo.copyWith(
|
||||
webPageUrl: isUrl(webPageUrlHeader ?? "") ? webPageUrlHeader : null,
|
||||
supportUrl: isUrl(supportUrlHeader ?? "") ? supportUrlHeader : null,
|
||||
);
|
||||
}
|
||||
|
||||
return RemoteProfile(
|
||||
id: const Uuid().v4(),
|
||||
active: false,
|
||||
name: title.isBlank ? "Remote Profile" : title,
|
||||
url: url,
|
||||
lastUpdate: DateTime.now(),
|
||||
options: options,
|
||||
subInfo: subInfo,
|
||||
);
|
||||
}
|
||||
|
||||
factory Profile.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfileFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfileOptions with _$ProfileOptions {
|
||||
const factory ProfileOptions({
|
||||
required Duration updateInterval,
|
||||
}) = _ProfileOptions;
|
||||
|
||||
factory ProfileOptions.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfileOptionsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SubscriptionInfo with _$SubscriptionInfo {
|
||||
const SubscriptionInfo._();
|
||||
|
||||
const factory SubscriptionInfo({
|
||||
required int upload,
|
||||
required int download,
|
||||
@JsonKey(fromJson: _fromJsonTotal, defaultValue: 9223372036854775807)
|
||||
required int total,
|
||||
@JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) required DateTime expire,
|
||||
String? webPageUrl,
|
||||
String? supportUrl,
|
||||
}) = _SubscriptionInfo;
|
||||
|
||||
bool get isExpired => expire <= DateTime.now();
|
||||
|
||||
int get consumption => upload + download;
|
||||
|
||||
double get ratio => (consumption / total).clamp(0, 1);
|
||||
|
||||
Duration get remaining => expire.difference(DateTime.now());
|
||||
|
||||
factory SubscriptionInfo.fromResponseHeader(String header) {
|
||||
final values = header.split(';');
|
||||
final map = {
|
||||
for (final v in values)
|
||||
v.split('=').first.trim():
|
||||
num.tryParse(v.split('=').second.trim())?.toInt(),
|
||||
};
|
||||
_loggy.debug("Subscription Info: $map");
|
||||
return SubscriptionInfo.fromJson(map);
|
||||
}
|
||||
|
||||
factory SubscriptionInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$SubscriptionInfoFromJson(json);
|
||||
}
|
||||
|
||||
int _fromJsonTotal(dynamic total) {
|
||||
final totalInt = total as int? ?? -1;
|
||||
return totalInt > 0 ? totalInt : 9223372036854775807;
|
||||
}
|
||||
|
||||
DateTime _dateTimeFromSecondsSinceEpoch(dynamic expire) {
|
||||
final expireInt = expire as int? ?? -1;
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
(expireInt > 0 ? expireInt : 92233720368) * 1000,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export 'profile.dart';
|
||||
export 'profile_enums.dart';
|
||||
export 'profiles_failure.dart';
|
||||
export 'profiles_repository.dart';
|
||||
@@ -1,37 +0,0 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/domain/enums.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
|
||||
abstract class ProfilesRepository {
|
||||
TaskEither<ProfileFailure, Profile?> get(String id);
|
||||
|
||||
Stream<Either<ProfileFailure, Profile?>> watchActiveProfile();
|
||||
|
||||
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile();
|
||||
|
||||
Stream<Either<ProfileFailure, List<Profile>>> watchAll({
|
||||
ProfilesSort sort = ProfilesSort.lastUpdate,
|
||||
SortMode mode = SortMode.ascending,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> addByUrl(
|
||||
String url, {
|
||||
bool markAsActive = false,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> addByContent(
|
||||
String content, {
|
||||
required String name,
|
||||
bool markAsActive = false,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> add(RemoteProfile baseProfile);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> update(RemoteProfile baseProfile);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> edit(Profile profile);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> setAsActive(String id);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> delete(String id);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'active_profile_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ActiveProfile extends _$ActiveProfile with AppLogger {
|
||||
@override
|
||||
Stream<Profile?> build() {
|
||||
loggy.debug("watching active profile");
|
||||
return ref
|
||||
.watch(profilesRepositoryProvider)
|
||||
.watchActiveProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'has_any_profile_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<bool> hasAnyProfile(
|
||||
HasAnyProfileRef ref,
|
||||
) {
|
||||
return ref
|
||||
.watch(profilesRepositoryProvider)
|
||||
.watchHasAnyProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
@@ -100,26 +100,4 @@ class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger {
|
||||
await _ignoreReleasePref.update(versionInfo.version);
|
||||
state = AppUpdateStateIgnored(versionInfo);
|
||||
}
|
||||
|
||||
// Future<void> _schedule() async {
|
||||
// loggy.debug("scheduling app update checker");
|
||||
// return ref.read(cronServiceProvider).schedule(
|
||||
// key: 'app_update',
|
||||
// duration: const Duration(hours: 8),
|
||||
// callback: () async {
|
||||
// await Future.delayed(const Duration(seconds: 5));
|
||||
// final updateState = await check();
|
||||
// final context = rootNavigatorKey.currentContext;
|
||||
// if (context != null && context.mounted) {
|
||||
// if (updateState
|
||||
// case AppUpdateStateAvailable(:final versionInfo)) {
|
||||
// await NewVersionDialog(
|
||||
// ref.read(appInfoProvider).presentVersion,
|
||||
// versionInfo,
|
||||
// ).show(context);
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:hiddify/core/prefs/general_prefs.dart';
|
||||
import 'package:hiddify/features/common/connectivity/connectivity_controller.dart';
|
||||
import 'package:hiddify/features/common/window/window_controller.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/profile/notifier/profiles_update_notifier.dart';
|
||||
import 'package:hiddify/features/system_tray/system_tray_controller.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:hiddify/utils/platform_utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
@@ -17,7 +16,7 @@ void commonControllers(CommonControllersRef ref) {
|
||||
introCompletedProvider,
|
||||
(_, completed) async {
|
||||
if (completed) {
|
||||
await ref.read(cronServiceProvider).startScheduler();
|
||||
await ref.read(foregroundProfilesUpdateNotifierProvider.future);
|
||||
}
|
||||
},
|
||||
fireImmediately: true,
|
||||
@@ -27,11 +26,6 @@ void commonControllers(CommonControllersRef ref) {
|
||||
(previous, next) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
ref.listen(
|
||||
profilesUpdateNotifierProvider,
|
||||
(previous, next) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
if (PlatformUtils.isDesktop) {
|
||||
ref.listen(
|
||||
windowControllerProvider,
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:hiddify/core/prefs/service_prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/domain/core_facade.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
@@ -25,7 +25,7 @@ class ConnectivityController extends _$ConnectivityController with AppLogger {
|
||||
},
|
||||
);
|
||||
return _core.watchConnectionStatus().doOnData((event) {
|
||||
if (event case Disconnected(:final connectionFailure?)
|
||||
if (event case Disconnected(connectionFailure: final _?)
|
||||
when PlatformUtils.isDesktop) {
|
||||
ref.read(startedByUserProvider.notifier).update(false);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/active_profile/has_any_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/nested_app_bar.dart';
|
||||
import 'package:hiddify/features/common/profile_tile.dart';
|
||||
import 'package:hiddify/features/home/widgets/widgets.dart';
|
||||
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/profile/widget/profile_tile.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/qr_code_scanner_screen.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/profile/notifier/profile_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -25,40 +23,26 @@ class AddProfileModal extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final addProfileState = ref.watch(addProfileProvider);
|
||||
|
||||
final mutationTriggered = useState(false);
|
||||
final addProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
mutationTriggered.value = false;
|
||||
if (err case ProfileInvalidUrlFailure()) {
|
||||
CustomToast.error(
|
||||
t.failure.profiles.invalidUrl,
|
||||
).show(context);
|
||||
} else {
|
||||
CustomAlertDialog.fromErr(
|
||||
t.presentError(err, action: t.profile.add.failureMsg),
|
||||
).show(context);
|
||||
ref.listen(
|
||||
addProfileProvider,
|
||||
(previous, next) {
|
||||
if (next case AsyncData(value: final _?)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
initialOnSuccess: () {
|
||||
CustomToast.success(t.profile.save.successMsg).show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final showProgressIndicator =
|
||||
addProfileMutation.state.isInProgress || mutationTriggered.value;
|
||||
|
||||
useMemoized(() async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
if (url != null && context.mounted) {
|
||||
addProfileMutation.setFuture(
|
||||
ref.read(profilesNotifierProvider.notifier).addProfile(url!),
|
||||
);
|
||||
if (addProfileState.isLoading) return;
|
||||
ref.read(addProfileProvider.notifier).add(url!);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,13 +96,10 @@ class AddProfileModal extends HookConsumerWidget {
|
||||
final captureResult =
|
||||
await Clipboard.getData(Clipboard.kTextPlain)
|
||||
.then((value) => value?.text ?? '');
|
||||
if (addProfileMutation.state.isInProgress) return;
|
||||
mutationTriggered.value = true;
|
||||
addProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.addProfile(captureResult),
|
||||
);
|
||||
if (addProfileState.isLoading) return;
|
||||
ref
|
||||
.read(addProfileProvider.notifier)
|
||||
.add(captureResult);
|
||||
},
|
||||
),
|
||||
const Gap(buttonsGap),
|
||||
@@ -133,15 +114,10 @@ class AddProfileModal extends HookConsumerWidget {
|
||||
await const QRCodeScannerScreen()
|
||||
.open(context);
|
||||
if (captureResult == null) return;
|
||||
if (addProfileMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
mutationTriggered.value = true;
|
||||
addProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.addProfile(captureResult),
|
||||
);
|
||||
if (addProfileState.isLoading) return;
|
||||
ref
|
||||
.read(addProfileProvider.notifier)
|
||||
.add(captureResult);
|
||||
},
|
||||
)
|
||||
else
|
||||
@@ -205,7 +181,7 @@ class AddProfileModal extends HookConsumerWidget {
|
||||
const Gap(24),
|
||||
],
|
||||
),
|
||||
crossFadeState: showProgressIndicator
|
||||
crossFadeState: addProfileState.isLoading
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
85
lib/features/profile/data/profile_data_mapper.dart
Normal file
85
lib/features/profile/data/profile_data_mapper.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
|
||||
extension ProfileEntityMapper on ProfileEntity {
|
||||
ProfileEntriesCompanion toEntry() {
|
||||
return switch (this) {
|
||||
RemoteProfileEntity(:final url, :final options, :final subInfo) =>
|
||||
ProfileEntriesCompanion.insert(
|
||||
id: id,
|
||||
type: ProfileType.remote,
|
||||
active: active,
|
||||
name: name,
|
||||
url: Value(url),
|
||||
lastUpdate: lastUpdate,
|
||||
updateInterval: Value(options?.updateInterval),
|
||||
upload: Value(subInfo?.upload),
|
||||
download: Value(subInfo?.download),
|
||||
total: Value(subInfo?.total),
|
||||
expire: Value(subInfo?.expire),
|
||||
webPageUrl: Value(subInfo?.webPageUrl),
|
||||
supportUrl: Value(subInfo?.supportUrl),
|
||||
),
|
||||
LocalProfileEntity() => ProfileEntriesCompanion.insert(
|
||||
id: id,
|
||||
type: ProfileType.local,
|
||||
active: active,
|
||||
name: name,
|
||||
lastUpdate: lastUpdate,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteProfileEntityMapper on RemoteProfileEntity {
|
||||
ProfileEntriesCompanion subInfoPatch() {
|
||||
return ProfileEntriesCompanion(
|
||||
upload: Value(subInfo?.upload),
|
||||
download: Value(subInfo?.download),
|
||||
total: Value(subInfo?.total),
|
||||
expire: Value(subInfo?.expire),
|
||||
webPageUrl: Value(subInfo?.webPageUrl),
|
||||
supportUrl: Value(subInfo?.supportUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileEntryMapper on ProfileEntry {
|
||||
ProfileEntity toEntity() {
|
||||
ProfileOptions? options;
|
||||
if (updateInterval != null) {
|
||||
options = ProfileOptions(updateInterval: updateInterval!);
|
||||
}
|
||||
|
||||
SubscriptionInfo? subInfo;
|
||||
if (upload != null && download != null && total != null && expire != null) {
|
||||
subInfo = SubscriptionInfo(
|
||||
upload: upload!,
|
||||
download: download!,
|
||||
total: total!,
|
||||
expire: expire!,
|
||||
webPageUrl: webPageUrl,
|
||||
supportUrl: supportUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return switch (type) {
|
||||
ProfileType.remote => RemoteProfileEntity(
|
||||
id: id,
|
||||
active: active,
|
||||
name: name,
|
||||
url: url!,
|
||||
lastUpdate: lastUpdate,
|
||||
options: options,
|
||||
subInfo: subInfo,
|
||||
),
|
||||
ProfileType.local => LocalProfileEntity(
|
||||
id: id,
|
||||
active: active,
|
||||
name: name,
|
||||
lastUpdate: lastUpdate,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
32
lib/features/profile/data/profile_data_providers.dart
Normal file
32
lib/features/profile/data/profile_data_providers.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_source.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_repository.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profile_data_providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<ProfileRepository> profileRepository(ProfileRepositoryRef ref) async {
|
||||
final repo = ProfileRepositoryImpl(
|
||||
profileDataSource: ref.watch(profileDataSourceProvider),
|
||||
profilePathResolver: ref.watch(profilePathResolverProvider),
|
||||
configValidator: ref.watch(coreFacadeProvider).parseConfig,
|
||||
dio: ref.watch(dioProvider),
|
||||
);
|
||||
await repo.init().getOrElse((l) => throw l).run();
|
||||
return repo;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfileDataSource profileDataSource(ProfileDataSourceRef ref) {
|
||||
return ProfileDao(ref.watch(appDatabaseProvider));
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfilePathResolver profilePathResolver(ProfilePathResolverRef ref) {
|
||||
return ProfilePathResolver(
|
||||
ref.watch(filesEditorServiceProvider).dirs.workingDir,
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/data_mappers.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/data/local/tables.dart';
|
||||
import 'package:hiddify/domain/enums.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'profiles_dao.g.dart';
|
||||
part 'profile_data_source.g.dart';
|
||||
|
||||
abstract interface class ProfileDataSource {
|
||||
Future<ProfileEntry?> getById(String id);
|
||||
Future<ProfileEntry?> getByUrl(String url);
|
||||
Stream<ProfileEntry?> watchActiveProfile();
|
||||
Stream<int> watchProfilesCount();
|
||||
Stream<List<ProfileEntry>> watchAll({
|
||||
required ProfilesSort sort,
|
||||
required SortMode sortMode,
|
||||
});
|
||||
Future<void> insert(ProfileEntriesCompanion entry);
|
||||
Future<void> edit(String id, ProfileEntriesCompanion entry);
|
||||
Future<void> deleteById(String id);
|
||||
}
|
||||
|
||||
Map<SortMode, OrderingMode> orderMap = {
|
||||
SortMode.ascending: OrderingMode.asc,
|
||||
@@ -14,41 +26,45 @@ Map<SortMode, OrderingMode> orderMap = {
|
||||
};
|
||||
|
||||
@DriftAccessor(tables: [ProfileEntries])
|
||||
class ProfilesDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$ProfilesDaoMixin, InfraLogger {
|
||||
ProfilesDao(super.db);
|
||||
class ProfileDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$ProfileDaoMixin, InfraLogger
|
||||
implements ProfileDataSource {
|
||||
ProfileDao(super.db);
|
||||
|
||||
Future<Profile?> getById(String id) async {
|
||||
@override
|
||||
Future<ProfileEntry?> getById(String id) async {
|
||||
return (profileEntries.select()..where((tbl) => tbl.id.equals(id)))
|
||||
.map(ProfileMapper.fromEntry)
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<Profile?> getProfileByUrl(String url) async {
|
||||
return (select(profileEntries)..where((tbl) => tbl.url.like('%$url%')))
|
||||
.map(ProfileMapper.fromEntry)
|
||||
.get()
|
||||
.then((value) => value.firstOrNull);
|
||||
@override
|
||||
Future<ProfileEntry?> getByUrl(String url) async {
|
||||
return (select(profileEntries)
|
||||
..where((tbl) => tbl.url.like('%$url%'))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Stream<Profile?> watchActiveProfile() {
|
||||
@override
|
||||
Stream<ProfileEntry?> watchActiveProfile() {
|
||||
return (profileEntries.select()
|
||||
..where((tbl) => tbl.active.equals(true))
|
||||
..limit(1))
|
||||
.map(ProfileMapper.fromEntry)
|
||||
.watchSingleOrNull();
|
||||
}
|
||||
|
||||
Stream<int> watchProfileCount() {
|
||||
@override
|
||||
Stream<int> watchProfilesCount() {
|
||||
final count = profileEntries.id.count();
|
||||
return (profileEntries.selectOnly()..addColumns([count]))
|
||||
.map((exp) => exp.read(count)!)
|
||||
.watchSingle();
|
||||
}
|
||||
|
||||
Stream<List<Profile>> watchAll({
|
||||
ProfilesSort sort = ProfilesSort.lastUpdate,
|
||||
SortMode mode = SortMode.ascending,
|
||||
@override
|
||||
Stream<List<ProfileEntry>> watchAll({
|
||||
required ProfilesSort sort,
|
||||
required SortMode sortMode,
|
||||
}) {
|
||||
return (profileEntries.select()
|
||||
..orderBy(
|
||||
@@ -67,56 +83,47 @@ class ProfilesDao extends DatabaseAccessor<AppDatabase>
|
||||
switch (sort) {
|
||||
ProfilesSort.name => (tbl) => OrderingTerm(
|
||||
expression: tbl.name,
|
||||
mode: orderMap[mode]!,
|
||||
mode: orderMap[sortMode]!,
|
||||
),
|
||||
ProfilesSort.lastUpdate => (tbl) => OrderingTerm(
|
||||
expression: tbl.lastUpdate,
|
||||
mode: orderMap[mode]!,
|
||||
mode: orderMap[sortMode]!,
|
||||
),
|
||||
},
|
||||
],
|
||||
))
|
||||
.map(ProfileMapper.fromEntry)
|
||||
.watch();
|
||||
}
|
||||
|
||||
Future<void> create(Profile profile) async {
|
||||
@override
|
||||
Future<void> insert(ProfileEntriesCompanion entry) async {
|
||||
await transaction(
|
||||
() async {
|
||||
if (profile.active) {
|
||||
if (entry.active.present && entry.active.value) {
|
||||
await update(profileEntries)
|
||||
.write(const ProfileEntriesCompanion(active: Value(false)));
|
||||
}
|
||||
await into(profileEntries).insert(profile.toCompanion());
|
||||
await into(profileEntries).insert(entry);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> edit(Profile patch) async {
|
||||
@override
|
||||
Future<void> edit(String id, ProfileEntriesCompanion entry) async {
|
||||
await transaction(
|
||||
() async {
|
||||
if (patch.active) {
|
||||
if (entry.active.present && entry.active.value) {
|
||||
await update(profileEntries)
|
||||
.write(const ProfileEntriesCompanion(active: Value(false)));
|
||||
}
|
||||
await (update(profileEntries)..where((tbl) => tbl.id.equals(patch.id)))
|
||||
.write(patch.toCompanion());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAsActive(String id) async {
|
||||
await transaction(
|
||||
() async {
|
||||
await update(profileEntries)
|
||||
.write(const ProfileEntriesCompanion(active: Value(false)));
|
||||
await (update(profileEntries)..where((tbl) => tbl.id.equals(id)))
|
||||
.write(const ProfileEntriesCompanion(active: Value(true)));
|
||||
.write(entry);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeById(String id) async {
|
||||
@override
|
||||
Future<void> deleteById(String id) async {
|
||||
await transaction(
|
||||
() async {
|
||||
await (delete(profileEntries)..where((tbl) => tbl.id.equals(id))).go();
|
||||
105
lib/features/profile/data/profile_parser.dart
Normal file
105
lib/features/profile/data/profile_parser.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// parse profile subscription url and headers for data
|
||||
///
|
||||
/// ***name parser hierarchy:***
|
||||
/// - `profile-title` header
|
||||
/// - `content-disposition` header
|
||||
/// - url fragment (example: `https://example.com/config#user`) -> name=`user`
|
||||
/// - url filename extension (example: `https://example.com/config.json`) -> name=`config`
|
||||
/// - if none of these methods return a non-blank string, fallback to `Remote Profile`
|
||||
abstract class ProfileParser {
|
||||
static RemoteProfileEntity parse(
|
||||
String url,
|
||||
Map<String, List<String>> headers,
|
||||
) {
|
||||
var name = '';
|
||||
if (headers['profile-title'] case [final titleHeader]) {
|
||||
if (titleHeader.startsWith("base64:")) {
|
||||
name =
|
||||
utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", "")));
|
||||
} else {
|
||||
name = titleHeader.trim();
|
||||
}
|
||||
}
|
||||
if (headers['content-disposition'] case [final contentDispositionHeader]
|
||||
when name.isEmpty) {
|
||||
final regExp = RegExp('filename="([^"]*)"');
|
||||
final match = regExp.firstMatch(contentDispositionHeader);
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
name = match.group(1) ?? '';
|
||||
}
|
||||
}
|
||||
if (Uri.parse(url).fragment case final fragment when name.isEmpty) {
|
||||
name = fragment;
|
||||
}
|
||||
if (url.split("/").lastOrNull case final part? when name.isEmpty) {
|
||||
final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*");
|
||||
name = part.replaceFirst(pattern, "");
|
||||
}
|
||||
if (name.isBlank) name = "Remote Profile";
|
||||
|
||||
ProfileOptions? options;
|
||||
if (headers['profile-update-interval'] case [final updateIntervalStr]) {
|
||||
final updateInterval = Duration(hours: int.parse(updateIntervalStr));
|
||||
options = ProfileOptions(updateInterval: updateInterval);
|
||||
}
|
||||
|
||||
SubscriptionInfo? subInfo;
|
||||
if (headers['subscription-userinfo'] case [final subInfoStr]) {
|
||||
subInfo = parseSubscriptionInfo(subInfoStr);
|
||||
}
|
||||
|
||||
if (subInfo != null) {
|
||||
if (headers['profile-web-page-url'] case [final profileWebPageUrl]
|
||||
when isUrl(profileWebPageUrl)) {
|
||||
subInfo = subInfo.copyWith(webPageUrl: profileWebPageUrl);
|
||||
}
|
||||
if (headers['support-url'] case [final profileSupportUrl]
|
||||
when isUrl(profileSupportUrl)) {
|
||||
subInfo = subInfo.copyWith(supportUrl: profileSupportUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return RemoteProfileEntity(
|
||||
id: const Uuid().v4(),
|
||||
active: false,
|
||||
name: name,
|
||||
url: url,
|
||||
lastUpdate: DateTime.now(),
|
||||
options: options,
|
||||
subInfo: subInfo,
|
||||
);
|
||||
}
|
||||
|
||||
static SubscriptionInfo? parseSubscriptionInfo(String subInfoStr) {
|
||||
final values = subInfoStr.split(';');
|
||||
final map = {
|
||||
for (final v in values)
|
||||
v.split('=').first.trim():
|
||||
num.tryParse(v.split('=').second.trim())?.toInt(),
|
||||
};
|
||||
if (map
|
||||
case {
|
||||
"upload": final upload?,
|
||||
"download": final download?,
|
||||
"total": final total,
|
||||
"expire": final expire
|
||||
}) {
|
||||
return SubscriptionInfo(
|
||||
upload: upload,
|
||||
download: download,
|
||||
total: total ?? 9223372036854775807,
|
||||
expire: DateTime.fromMillisecondsSinceEpoch(
|
||||
(expire ?? 92233720368) * 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
17
lib/features/profile/data/profile_path_resolver.dart
Normal file
17
lib/features/profile/data/profile_path_resolver.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class ProfilePathResolver {
|
||||
const ProfilePathResolver(this._workingDir);
|
||||
|
||||
final Directory _workingDir;
|
||||
|
||||
Directory get directory => Directory(p.join(_workingDir.path, "configs"));
|
||||
|
||||
File file(String fileName) {
|
||||
return File(p.join(directory.path, "$fileName.json"));
|
||||
}
|
||||
|
||||
File tempFile(String fileName) => file("$fileName.tmp");
|
||||
}
|
||||
@@ -1,44 +1,103 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/local/dao/profiles_dao.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/data/repository/exception_handlers.dart';
|
||||
import 'package:hiddify/domain/enums.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/domain/singbox/singbox.dart';
|
||||
import 'package:hiddify/services/files_editor_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hiddify/domain/core_service_failure.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_mapper.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_source.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_parser.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_failure.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
import 'package:hiddify/utils/link_parsers.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:retry/retry.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ProfilesRepositoryImpl
|
||||
abstract interface class ProfileRepository {
|
||||
TaskEither<ProfileFailure, Unit> init();
|
||||
TaskEither<ProfileFailure, ProfileEntity?> getById(String id);
|
||||
Stream<Either<ProfileFailure, ProfileEntity?>> watchActiveProfile();
|
||||
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile();
|
||||
|
||||
Stream<Either<ProfileFailure, List<ProfileEntity>>> watchAll({
|
||||
ProfilesSort sort = ProfilesSort.lastUpdate,
|
||||
SortMode sortMode = SortMode.ascending,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> addByUrl(
|
||||
String url, {
|
||||
bool markAsActive = false,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> addByContent(
|
||||
String content, {
|
||||
required String name,
|
||||
bool markAsActive = false,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> add(RemoteProfileEntity baseProfile);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> updateSubscription(
|
||||
RemoteProfileEntity baseProfile,
|
||||
);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> patch(ProfileEntity profile);
|
||||
TaskEither<ProfileFailure, Unit> setAsActive(String id);
|
||||
TaskEither<ProfileFailure, Unit> deleteById(String id);
|
||||
}
|
||||
|
||||
class ProfileRepositoryImpl
|
||||
with ExceptionHandler, InfraLogger
|
||||
implements ProfilesRepository {
|
||||
ProfilesRepositoryImpl({
|
||||
required this.profilesDao,
|
||||
required this.filesEditor,
|
||||
required this.singbox,
|
||||
implements ProfileRepository {
|
||||
ProfileRepositoryImpl({
|
||||
required this.profileDataSource,
|
||||
required this.profilePathResolver,
|
||||
required this.configValidator,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
final ProfilesDao profilesDao;
|
||||
final FilesEditorService filesEditor;
|
||||
final SingboxFacade singbox;
|
||||
final ProfileDataSource profileDataSource;
|
||||
final ProfilePathResolver profilePathResolver;
|
||||
final TaskEither<CoreServiceFailure, Unit> Function(
|
||||
String path,
|
||||
String tempPath,
|
||||
bool debug,
|
||||
) configValidator;
|
||||
final Dio dio;
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Profile?> get(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() => profilesDao.getById(id),
|
||||
TaskEither<ProfileFailure, Unit> init() {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
if (!await profilePathResolver.directory.exists()) {
|
||||
await profilePathResolver.directory.create(recursive: true);
|
||||
}
|
||||
return right(unit);
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, Profile?>> watchActiveProfile() {
|
||||
return profilesDao.watchActiveProfile().handleExceptions(
|
||||
TaskEither<ProfileFailure, ProfileEntity?> getById(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() => profileDataSource.getById(id).then((value) => value?.toEntity()),
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, ProfileEntity?>> watchActiveProfile() {
|
||||
return profileDataSource
|
||||
.watchActiveProfile()
|
||||
.map((event) => event?.toEntity())
|
||||
.handleExceptions(
|
||||
(error, stackTrace) {
|
||||
loggy.error("error watching active profile", error, stackTrace);
|
||||
return ProfileUnexpectedFailure(error, stackTrace);
|
||||
@@ -48,19 +107,20 @@ class ProfilesRepositoryImpl
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile() {
|
||||
return profilesDao
|
||||
.watchProfileCount()
|
||||
return profileDataSource
|
||||
.watchProfilesCount()
|
||||
.map((event) => event != 0)
|
||||
.handleExceptions(ProfileUnexpectedFailure.new);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, List<Profile>>> watchAll({
|
||||
Stream<Either<ProfileFailure, List<ProfileEntity>>> watchAll({
|
||||
ProfilesSort sort = ProfilesSort.lastUpdate,
|
||||
SortMode mode = SortMode.ascending,
|
||||
SortMode sortMode = SortMode.ascending,
|
||||
}) {
|
||||
return profilesDao
|
||||
.watchAll(sort: sort, mode: mode)
|
||||
return profileDataSource
|
||||
.watchAll(sort: sort, sortMode: sortMode)
|
||||
.map((event) => event.map((e) => e.toEntity()).toList())
|
||||
.handleExceptions(ProfileUnexpectedFailure.new);
|
||||
}
|
||||
|
||||
@@ -71,13 +131,15 @@ class ProfilesRepositoryImpl
|
||||
}) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
final existingProfile = await profilesDao.getProfileByUrl(url);
|
||||
if (existingProfile case RemoteProfile()) {
|
||||
final existingProfile = await profileDataSource
|
||||
.getByUrl(url)
|
||||
.then((value) => value?.toEntity());
|
||||
if (existingProfile case RemoteProfileEntity()) {
|
||||
loggy.info("profile with same url already exists, updating");
|
||||
final baseProfile = markAsActive
|
||||
? existingProfile.copyWith(active: true)
|
||||
: existingProfile;
|
||||
return update(baseProfile).run();
|
||||
return updateSubscription(baseProfile).run();
|
||||
}
|
||||
|
||||
final profileId = const Uuid().v4();
|
||||
@@ -85,11 +147,10 @@ class ProfilesRepositoryImpl
|
||||
.flatMap(
|
||||
(profile) => TaskEither(
|
||||
() async {
|
||||
await profilesDao.create(
|
||||
profile.copyWith(
|
||||
id: profileId,
|
||||
active: markAsActive,
|
||||
),
|
||||
await profileDataSource.insert(
|
||||
profile
|
||||
.copyWith(id: profileId, active: markAsActive)
|
||||
.toEntry(),
|
||||
);
|
||||
return right(unit);
|
||||
},
|
||||
@@ -113,30 +174,31 @@ class ProfilesRepositoryImpl
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
final profileId = const Uuid().v4();
|
||||
final tempPath = filesEditor.tempConfigPath(profileId);
|
||||
final path = filesEditor.configPath(profileId);
|
||||
final file = profilePathResolver.file(profileId);
|
||||
final tempFile = profilePathResolver.tempFile(profileId);
|
||||
|
||||
try {
|
||||
await File(tempPath).writeAsString(content);
|
||||
await tempFile.writeAsString(content);
|
||||
final parseResult =
|
||||
await singbox.parseConfig(path, tempPath, false).run();
|
||||
await configValidator(file.path, tempFile.path, false).run();
|
||||
return parseResult.fold(
|
||||
(err) async {
|
||||
loggy.warning("error parsing config", err);
|
||||
return left(ProfileFailure.invalidConfig(err.msg));
|
||||
},
|
||||
(_) async {
|
||||
final profile = LocalProfile(
|
||||
final profile = LocalProfileEntity(
|
||||
id: profileId,
|
||||
active: markAsActive,
|
||||
name: name,
|
||||
lastUpdate: DateTime.now(),
|
||||
);
|
||||
await profilesDao.create(profile);
|
||||
await profileDataSource.insert(profile.toEntry());
|
||||
return right(unit);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (File(tempPath).existsSync()) File(tempPath).deleteSync();
|
||||
if (tempFile.existsSync()) tempFile.deleteSync();
|
||||
}
|
||||
},
|
||||
(error, stackTrace) {
|
||||
@@ -147,17 +209,19 @@ class ProfilesRepositoryImpl
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> add(RemoteProfile baseProfile) {
|
||||
TaskEither<ProfileFailure, Unit> add(RemoteProfileEntity baseProfile) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
return fetch(baseProfile.url, baseProfile.id)
|
||||
.flatMap(
|
||||
(remoteProfile) => TaskEither(() async {
|
||||
await profilesDao.create(
|
||||
baseProfile.copyWith(
|
||||
subInfo: remoteProfile.subInfo,
|
||||
lastUpdate: DateTime.now(),
|
||||
),
|
||||
await profileDataSource.insert(
|
||||
baseProfile
|
||||
.copyWith(
|
||||
subInfo: remoteProfile.subInfo,
|
||||
lastUpdate: DateTime.now(),
|
||||
)
|
||||
.toEntry(),
|
||||
);
|
||||
return right(unit);
|
||||
}),
|
||||
@@ -172,7 +236,9 @@ class ProfilesRepositoryImpl
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> update(RemoteProfile baseProfile) {
|
||||
TaskEither<ProfileFailure, Unit> updateSubscription(
|
||||
RemoteProfileEntity baseProfile,
|
||||
) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
loggy.debug(
|
||||
@@ -181,11 +247,11 @@ class ProfilesRepositoryImpl
|
||||
return fetch(baseProfile.url, baseProfile.id)
|
||||
.flatMap(
|
||||
(remoteProfile) => TaskEither(() async {
|
||||
await profilesDao.edit(
|
||||
baseProfile.copyWith(
|
||||
subInfo: remoteProfile.subInfo,
|
||||
lastUpdate: DateTime.now(),
|
||||
),
|
||||
await profileDataSource.edit(
|
||||
baseProfile.id,
|
||||
remoteProfile
|
||||
.subInfoPatch()
|
||||
.copyWith(lastUpdate: Value(DateTime.now())),
|
||||
);
|
||||
return right(unit);
|
||||
}),
|
||||
@@ -200,13 +266,13 @@ class ProfilesRepositoryImpl
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> edit(Profile profile) {
|
||||
TaskEither<ProfileFailure, Unit> patch(ProfileEntity profile) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
loggy.debug(
|
||||
"editing profile [${profile.name} (${profile.id})]",
|
||||
);
|
||||
await profilesDao.edit(profile);
|
||||
await profileDataSource.edit(profile.id, profile.toEntry());
|
||||
return right(unit);
|
||||
},
|
||||
(error, stackTrace) {
|
||||
@@ -220,7 +286,10 @@ class ProfilesRepositoryImpl
|
||||
TaskEither<ProfileFailure, Unit> setAsActive(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() async {
|
||||
await profilesDao.setAsActive(id);
|
||||
await profileDataSource.edit(
|
||||
id,
|
||||
const ProfileEntriesCompanion(active: Value(true)),
|
||||
);
|
||||
return unit;
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
@@ -228,11 +297,11 @@ class ProfilesRepositoryImpl
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> delete(String id) {
|
||||
TaskEither<ProfileFailure, Unit> deleteById(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() async {
|
||||
await profilesDao.removeById(id);
|
||||
await filesEditor.deleteConfig(id);
|
||||
await profileDataSource.deleteById(id);
|
||||
await profilePathResolver.file(id).delete();
|
||||
return unit;
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
@@ -249,35 +318,35 @@ class ProfilesRepositoryImpl
|
||||
];
|
||||
|
||||
@visibleForTesting
|
||||
TaskEither<ProfileFailure, RemoteProfile> fetch(
|
||||
TaskEither<ProfileFailure, RemoteProfileEntity> fetch(
|
||||
String url,
|
||||
String fileName,
|
||||
) {
|
||||
return TaskEither(
|
||||
() async {
|
||||
final tempPath = filesEditor.tempConfigPath(fileName);
|
||||
final path = filesEditor.configPath(fileName);
|
||||
final file = profilePathResolver.file(fileName);
|
||||
final tempFile = profilePathResolver.tempFile(fileName);
|
||||
try {
|
||||
final response = await retry(
|
||||
() async => dio.download(url.trim(), tempPath),
|
||||
() async => dio.download(url.trim(), tempFile.path),
|
||||
maxAttempts: 3,
|
||||
);
|
||||
final headers =
|
||||
await _populateHeaders(response.headers.map, tempPath);
|
||||
await _populateHeaders(response.headers.map, tempFile.path);
|
||||
final parseResult =
|
||||
await singbox.parseConfig(path, tempPath, false).run();
|
||||
await configValidator(file.path, tempFile.path, false).run();
|
||||
return parseResult.fold(
|
||||
(err) async {
|
||||
loggy.warning("error parsing config", err);
|
||||
return left(ProfileFailure.invalidConfig(err.msg));
|
||||
},
|
||||
(_) async {
|
||||
final profile = Profile.fromResponse(url, headers);
|
||||
final profile = ProfileParser.parse(url, headers);
|
||||
return right(profile);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (File(tempPath).existsSync()) File(tempPath).deleteSync();
|
||||
if (tempFile.existsSync()) tempFile.deleteSync();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,25 +1,27 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/profile_detail/notifier/profile_detail_state.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_repository.dart';
|
||||
import 'package:hiddify/features/profile/details/profile_details_state.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_failure.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'profile_detail_notifier.g.dart';
|
||||
part 'profile_details_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger {
|
||||
@override
|
||||
Future<ProfileDetailState> build(
|
||||
Future<ProfileDetailsState> build(
|
||||
String id, {
|
||||
String? url,
|
||||
String? profileName,
|
||||
}) async {
|
||||
if (id == 'new') {
|
||||
return ProfileDetailState(
|
||||
profile: RemoteProfile(
|
||||
return ProfileDetailsState(
|
||||
profile: RemoteProfileEntity(
|
||||
id: const Uuid().v4(),
|
||||
active: true,
|
||||
name: profileName ?? "",
|
||||
@@ -28,7 +30,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
),
|
||||
);
|
||||
}
|
||||
final failureOrProfile = await _profilesRepo.get(id).run();
|
||||
final failureOrProfile = await _profilesRepo.getById(id).run();
|
||||
return failureOrProfile.match(
|
||||
(err) {
|
||||
loggy.warning('failed to load profile', err);
|
||||
@@ -40,13 +42,14 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
throw const ProfileNotFoundFailure();
|
||||
}
|
||||
_originalProfile = profile;
|
||||
return ProfileDetailState(profile: profile, isEditing: true);
|
||||
return ProfileDetailsState(profile: profile, isEditing: true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider);
|
||||
Profile? _originalProfile;
|
||||
ProfileRepository get _profilesRepo =>
|
||||
ref.read(profileRepositoryProvider).requireValue;
|
||||
ProfileEntity? _originalProfile;
|
||||
|
||||
void setField({String? name, String? url, Option<int>? updateInterval}) {
|
||||
if (state case AsyncData(:final value)) {
|
||||
@@ -74,41 +77,47 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
|
||||
Future<void> save() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
if (value.save.isInProgress) return;
|
||||
if (value.save case AsyncLoading()) return;
|
||||
|
||||
final profile = value.profile;
|
||||
Either<ProfileFailure, Unit>? failureOrSuccess;
|
||||
state = AsyncData(value.copyWith(save: const MutationInProgress()));
|
||||
state = AsyncData(value.copyWith(save: const AsyncLoading()));
|
||||
|
||||
switch (profile) {
|
||||
case RemoteProfile():
|
||||
case RemoteProfileEntity():
|
||||
loggy.debug(
|
||||
'saving profile, url: [${profile.url}], name: [${profile.name}]',
|
||||
);
|
||||
if (profile.name.isBlank || profile.url.isBlank) {
|
||||
loggy.debug('profile save: invalid arguments');
|
||||
loggy.debug('save: invalid arguments');
|
||||
} else if (value.isEditing) {
|
||||
if (_originalProfile case RemoteProfile(:final url)
|
||||
if (_originalProfile case RemoteProfileEntity(:final url)
|
||||
when url == profile.url) {
|
||||
loggy.debug('editing profile');
|
||||
failureOrSuccess = await _profilesRepo.edit(profile).run();
|
||||
failureOrSuccess = await _profilesRepo.patch(profile).run();
|
||||
} else {
|
||||
loggy.debug('updating profile');
|
||||
failureOrSuccess = await _profilesRepo.update(profile).run();
|
||||
failureOrSuccess =
|
||||
await _profilesRepo.updateSubscription(profile).run();
|
||||
}
|
||||
} else {
|
||||
loggy.debug('adding profile, url: [${profile.url}]');
|
||||
failureOrSuccess = await _profilesRepo.add(profile).run();
|
||||
}
|
||||
case LocalProfile() when value.isEditing:
|
||||
|
||||
case LocalProfileEntity() when value.isEditing:
|
||||
loggy.debug('editing profile');
|
||||
failureOrSuccess = await _profilesRepo.edit(profile).run();
|
||||
failureOrSuccess = await _profilesRepo.patch(profile).run();
|
||||
|
||||
default:
|
||||
loggy.warning("local profile can't be added manually");
|
||||
}
|
||||
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
save: failureOrSuccess?.fold(
|
||||
(l) => MutationFailure(l),
|
||||
(_) => const MutationSuccess(),
|
||||
(l) => AsyncError(l, StackTrace.current),
|
||||
(_) => const AsyncData(null),
|
||||
) ??
|
||||
value.save,
|
||||
showErrorMessages: true,
|
||||
@@ -119,24 +128,25 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
|
||||
Future<void> updateProfile() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
loggy.debug('updating profile');
|
||||
if (value.profile case LocalProfile()) {
|
||||
if (value.update?.isLoading ?? false || !value.isEditing) return;
|
||||
if (value.profile case LocalProfileEntity()) {
|
||||
loggy.warning("local profile can't be updated");
|
||||
return;
|
||||
}
|
||||
if (value.update.isInProgress || !value.isEditing) return;
|
||||
|
||||
final profile = value.profile;
|
||||
loggy.debug('updating profile');
|
||||
state = AsyncData(value.copyWith(update: const MutationInProgress()));
|
||||
state = AsyncData(value.copyWith(update: const AsyncLoading()));
|
||||
|
||||
final failureOrUpdatedProfile = await _profilesRepo
|
||||
.update(profile as RemoteProfile)
|
||||
.flatMap((_) => _profilesRepo.get(id))
|
||||
.updateSubscription(profile as RemoteProfileEntity)
|
||||
.flatMap((_) => _profilesRepo.getById(id))
|
||||
.run();
|
||||
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
update: failureOrUpdatedProfile.match(
|
||||
(l) => MutationFailure(l),
|
||||
(_) => const MutationSuccess(),
|
||||
(l) => AsyncError(l, StackTrace.current),
|
||||
(_) => const AsyncData(null),
|
||||
),
|
||||
profile: failureOrUpdatedProfile.match(
|
||||
(_) => profile,
|
||||
@@ -149,17 +159,18 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
|
||||
Future<void> delete() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
if (value.delete.isInProgress) return;
|
||||
if (value.delete case AsyncLoading()) return;
|
||||
final profile = value.profile;
|
||||
loggy.debug('deleting profile');
|
||||
state = AsyncData(value.copyWith(delete: const MutationInProgress()));
|
||||
final result = await _profilesRepo.delete(profile.id).run();
|
||||
state = AsyncData(value.copyWith(delete: const AsyncLoading()));
|
||||
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
delete: result.match(
|
||||
(l) => MutationFailure(l),
|
||||
(_) => const MutationSuccess(),
|
||||
),
|
||||
delete: await AsyncValue.guard(() async {
|
||||
await _profilesRepo
|
||||
.deleteById(profile.id)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,16 @@ import 'package:fpdart/fpdart.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/confirmation_dialogs.dart';
|
||||
import 'package:hiddify/features/profile_detail/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/profile/details/profile_details_notifier.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/features/settings/widgets/widgets.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:humanizer/humanizer.dart';
|
||||
|
||||
class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
const ProfileDetailPage(this.id, {super.key});
|
||||
class ProfileDetailsPage extends HookConsumerWidget with PresLogger {
|
||||
const ProfileDetailsPage(this.id, {super.key});
|
||||
|
||||
final String id;
|
||||
|
||||
@@ -20,65 +20,59 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final provider = profileDetailNotifierProvider(id);
|
||||
final provider = profileDetailsNotifierProvider(id);
|
||||
final notifier = ref.watch(provider.notifier);
|
||||
|
||||
ref.listen(
|
||||
provider.select((data) => data.whenData((value) => value.save)),
|
||||
(_, asyncSave) {
|
||||
if (asyncSave case AsyncData(value: final save)) {
|
||||
switch (save) {
|
||||
case MutationFailure(:final failure):
|
||||
final String action;
|
||||
if (ref.read(provider) case AsyncData(value: final data)
|
||||
when data.isEditing) {
|
||||
action = t.profile.save.failureMsg;
|
||||
} else {
|
||||
action = t.profile.add.failureMsg;
|
||||
}
|
||||
CustomAlertDialog.fromErr(t.presentError(failure, action: action))
|
||||
.show(context);
|
||||
case MutationSuccess():
|
||||
CustomToast.success(t.profile.save.successMsg).show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
provider.selectAsync((data) => data.save),
|
||||
(_, next) async {
|
||||
switch (await next) {
|
||||
case AsyncData():
|
||||
CustomToast.success(t.profile.save.successMsg).show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
case AsyncError(:final error):
|
||||
final String action;
|
||||
if (ref.read(provider) case AsyncData(value: final data)
|
||||
when data.isEditing) {
|
||||
action = t.profile.save.failureMsg;
|
||||
} else {
|
||||
action = t.profile.add.failureMsg;
|
||||
}
|
||||
CustomAlertDialog.fromErr(t.presentError(error, action: action))
|
||||
.show(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ref.listen(
|
||||
provider.select((data) => data.whenData((value) => value.update)),
|
||||
(_, asyncUpdate) {
|
||||
if (asyncUpdate case AsyncData(value: final update)) {
|
||||
switch (update) {
|
||||
case MutationFailure(:final failure):
|
||||
CustomAlertDialog.fromErr(t.presentError(failure)).show(context);
|
||||
case MutationSuccess():
|
||||
CustomToast.success(t.profile.update.successMsg).show(context);
|
||||
}
|
||||
provider.selectAsync((data) => data.update),
|
||||
(_, next) async {
|
||||
switch (await next) {
|
||||
case AsyncData():
|
||||
CustomToast.success(t.profile.update.successMsg).show(context);
|
||||
case AsyncError(:final error):
|
||||
CustomAlertDialog.fromErr(t.presentError(error)).show(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ref.listen(
|
||||
provider.select((data) => data.whenData((value) => value.delete)),
|
||||
(_, asyncDelete) {
|
||||
if (asyncDelete case AsyncData(value: final delete)) {
|
||||
switch (delete) {
|
||||
case MutationFailure(:final failure):
|
||||
CustomToast.error(t.presentShortError(failure)).show(context);
|
||||
case MutationSuccess():
|
||||
CustomToast.success(t.profile.delete.successMsg).show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
provider.selectAsync((data) => data.delete),
|
||||
(_, next) async {
|
||||
switch (await next) {
|
||||
case AsyncData():
|
||||
CustomToast.success(t.profile.delete.successMsg).show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
case AsyncError(:final error):
|
||||
CustomToast.error(t.presentShortError(error)).show(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -102,7 +96,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
if (state.profile case RemoteProfile())
|
||||
if (state.profile case RemoteProfileEntity())
|
||||
PopupMenuItem(
|
||||
child: Text(t.profile.update.buttonTxt),
|
||||
onTap: () async {
|
||||
@@ -151,7 +145,10 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
),
|
||||
),
|
||||
if (state.profile
|
||||
case RemoteProfile(:final url, :final options)) ...[
|
||||
case RemoteProfileEntity(
|
||||
:final url,
|
||||
:final options
|
||||
)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
22
lib/features/profile/details/profile_details_state.dart
Normal file
22
lib/features/profile/details/profile_details_state.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
part 'profile_details_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ProfileDetailsState with _$ProfileDetailsState {
|
||||
const ProfileDetailsState._();
|
||||
|
||||
const factory ProfileDetailsState({
|
||||
required ProfileEntity profile,
|
||||
@Default(false) bool isEditing,
|
||||
@Default(false) bool showErrorMessages,
|
||||
AsyncValue<void>? save,
|
||||
AsyncValue<void>? update,
|
||||
AsyncValue<void>? delete,
|
||||
}) = _ProfileDetailsState;
|
||||
|
||||
bool get isBusy =>
|
||||
save is AsyncLoading || delete is AsyncLoading || update is AsyncLoading;
|
||||
}
|
||||
57
lib/features/profile/model/profile_entity.dart
Normal file
57
lib/features/profile/model/profile_entity.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'profile_entity.freezed.dart';
|
||||
|
||||
enum ProfileType { remote, local }
|
||||
|
||||
@freezed
|
||||
sealed class ProfileEntity with _$ProfileEntity {
|
||||
const ProfileEntity._();
|
||||
|
||||
const factory ProfileEntity.remote({
|
||||
required String id,
|
||||
required bool active,
|
||||
required String name,
|
||||
required String url,
|
||||
required DateTime lastUpdate,
|
||||
ProfileOptions? options,
|
||||
SubscriptionInfo? subInfo,
|
||||
}) = RemoteProfileEntity;
|
||||
|
||||
const factory ProfileEntity.local({
|
||||
required String id,
|
||||
required bool active,
|
||||
required String name,
|
||||
required DateTime lastUpdate,
|
||||
}) = LocalProfileEntity;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfileOptions with _$ProfileOptions {
|
||||
const factory ProfileOptions({
|
||||
required Duration updateInterval,
|
||||
}) = _ProfileOptions;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SubscriptionInfo with _$SubscriptionInfo {
|
||||
const SubscriptionInfo._();
|
||||
|
||||
const factory SubscriptionInfo({
|
||||
required int upload,
|
||||
required int download,
|
||||
required int total,
|
||||
required DateTime expire,
|
||||
String? webPageUrl,
|
||||
String? supportUrl,
|
||||
}) = _SubscriptionInfo;
|
||||
|
||||
bool get isExpired => expire <= DateTime.now();
|
||||
|
||||
int get consumption => upload + download;
|
||||
|
||||
double get ratio => (consumption / total).clamp(0, 1);
|
||||
|
||||
Duration get remaining => expire.difference(DateTime.now());
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
|
||||
part 'profiles_failure.freezed.dart';
|
||||
part 'profile_failure.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ProfileFailure with _$ProfileFailure, Failure {
|
||||
@@ -17,3 +17,5 @@ enum ProfilesSort {
|
||||
name => Icons.sort_by_alpha,
|
||||
};
|
||||
}
|
||||
|
||||
enum SortMode { ascending, descending }
|
||||
31
lib/features/profile/notifier/active_profile_notifier.dart
Normal file
31
lib/features/profile/notifier/active_profile_notifier.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'active_profile_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ActiveProfile extends _$ActiveProfile with AppLogger {
|
||||
@override
|
||||
Stream<ProfileEntity?> build() {
|
||||
loggy.debug("watching active profile");
|
||||
return ref
|
||||
.watch(profileRepositoryProvider)
|
||||
.requireValue
|
||||
.watchActiveProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: move to specific feature
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<bool> hasAnyProfile(
|
||||
HasAnyProfileRef ref,
|
||||
) {
|
||||
return ref
|
||||
.watch(profileRepositoryProvider)
|
||||
.requireValue
|
||||
.watchHasAnyProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
140
lib/features/profile/notifier/profile_notifier.dart
Normal file
140
lib/features/profile/notifier/profile_notifier.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/notification/in_app_notification_controller.dart';
|
||||
import 'package:hiddify/core/prefs/general_prefs.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/connectivity/connectivity_controller.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_repository.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_failure.dart';
|
||||
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
|
||||
import 'package:hiddify/utils/riverpod_utils.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profile_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class AddProfile extends _$AddProfile with AppLogger {
|
||||
@override
|
||||
AsyncValue<Unit?> build() {
|
||||
ref.disposeDelay(const Duration(minutes: 1));
|
||||
ref.listenSelf(
|
||||
(previous, next) {
|
||||
final t = ref.read(translationsProvider);
|
||||
final notification = ref.read(inAppNotificationControllerProvider);
|
||||
switch (next) {
|
||||
case AsyncData(value: final _?):
|
||||
notification.showSuccessToast(t.profile.save.successMsg);
|
||||
case AsyncError(:final error):
|
||||
if (error case ProfileInvalidUrlFailure()) {
|
||||
notification.showErrorToast(t.failure.profiles.invalidUrl);
|
||||
} else {
|
||||
notification.showErrorDialog(
|
||||
t.presentError(error, action: t.profile.add.failureMsg),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return const AsyncData(null);
|
||||
}
|
||||
|
||||
ProfileRepository get _profilesRepo =>
|
||||
ref.read(profileRepositoryProvider).requireValue;
|
||||
|
||||
Future<void> add(String rawInput) async {
|
||||
if (state.isLoading) return;
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() async {
|
||||
final activeProfile = await ref.read(activeProfileProvider.future);
|
||||
final markAsActive =
|
||||
activeProfile == null || ref.read(markNewProfileActiveProvider);
|
||||
final TaskEither<ProfileFailure, Unit> task;
|
||||
if (LinkParser.parse(rawInput) case (final link)?) {
|
||||
loggy.debug("adding profile, url: [${link.url}]");
|
||||
task = _profilesRepo.addByUrl(link.url, markAsActive: markAsActive);
|
||||
} else if (LinkParser.protocol(rawInput) case (final parsed)?) {
|
||||
loggy.debug("adding profile, content");
|
||||
task = _profilesRepo.addByContent(
|
||||
parsed.content,
|
||||
name: parsed.name,
|
||||
markAsActive: markAsActive,
|
||||
);
|
||||
} else {
|
||||
loggy.debug("invalid content");
|
||||
throw const ProfileInvalidUrlFailure();
|
||||
}
|
||||
return task.match(
|
||||
(err) {
|
||||
loggy.warning("failed to add profile", err);
|
||||
throw err;
|
||||
},
|
||||
(_) {
|
||||
loggy.info(
|
||||
"successfully added profile, mark as active? [$markAsActive]",
|
||||
);
|
||||
return unit;
|
||||
},
|
||||
).run();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class UpdateProfile extends _$UpdateProfile with AppLogger {
|
||||
@override
|
||||
AsyncValue<Unit?> build(String id) {
|
||||
ref.disposeDelay(const Duration(minutes: 1));
|
||||
ref.listenSelf(
|
||||
(previous, next) {
|
||||
final t = ref.read(translationsProvider);
|
||||
final notification = ref.read(inAppNotificationControllerProvider);
|
||||
switch (next) {
|
||||
case AsyncData(value: final _?):
|
||||
notification.showSuccessToast(t.profile.update.successMsg);
|
||||
case AsyncError(:final error):
|
||||
notification.showErrorDialog(
|
||||
t.presentError(error, action: t.profile.update.failureMsg),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
return const AsyncData(null);
|
||||
}
|
||||
|
||||
ProfileRepository get _profilesRepo =>
|
||||
ref.read(profileRepositoryProvider).requireValue;
|
||||
|
||||
Future<void> updateProfile(RemoteProfileEntity profile) async {
|
||||
if (state.isLoading) return;
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() async {
|
||||
return await _profilesRepo.updateSubscription(profile).match(
|
||||
(err) {
|
||||
loggy.warning("failed to update profile", err);
|
||||
throw err;
|
||||
},
|
||||
(_) async {
|
||||
loggy.info(
|
||||
'successfully updated profile, was active? [${profile.active}]',
|
||||
);
|
||||
|
||||
await ref.read(activeProfileProvider.future).then((active) async {
|
||||
if (active != null && active.id == profile.id) {
|
||||
await ref
|
||||
.read(connectivityControllerProvider.notifier)
|
||||
.reconnect(profile.id);
|
||||
}
|
||||
});
|
||||
return unit;
|
||||
},
|
||||
).run();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
92
lib/features/profile/notifier/profiles_update_notifier.dart
Normal file
92
lib/features/profile/notifier/profiles_update_notifier.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:neat_periodic_task/neat_periodic_task.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profiles_update_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ForegroundProfilesUpdateNotifier
|
||||
extends _$ForegroundProfilesUpdateNotifier with AppLogger {
|
||||
static const prefKey = "profiles_update_check";
|
||||
static const interval = Duration(minutes: 15);
|
||||
|
||||
@override
|
||||
Future<void> build() async {
|
||||
loggy.debug("initializing");
|
||||
var cycleCount = 0;
|
||||
final scheduler = NeatPeriodicTaskScheduler(
|
||||
name: 'profiles update worker',
|
||||
interval: interval,
|
||||
timeout: const Duration(minutes: 5),
|
||||
task: () async {
|
||||
loggy.debug("cycle [${cycleCount++}]");
|
||||
await updateProfiles();
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(() async {
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
return scheduler.start();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> updateProfiles() async {
|
||||
try {
|
||||
final previousRun = DateTime.tryParse(
|
||||
ref.read(sharedPreferencesProvider).getString(prefKey) ?? "",
|
||||
);
|
||||
|
||||
if (previousRun != null && previousRun.add(interval) > DateTime.now()) {
|
||||
loggy.debug("too soon! previous run: [$previousRun]");
|
||||
return;
|
||||
}
|
||||
loggy.debug("running, previous run: [$previousRun]");
|
||||
|
||||
final remoteProfiles = await ref
|
||||
.read(profileRepositoryProvider)
|
||||
.requireValue
|
||||
.watchAll()
|
||||
.map(
|
||||
(event) => event.getOrElse((f) {
|
||||
loggy.error("error getting profiles");
|
||||
throw f;
|
||||
}).whereType<RemoteProfileEntity>(),
|
||||
)
|
||||
.first;
|
||||
|
||||
await for (final profile in Stream.fromIterable(remoteProfiles)) {
|
||||
final updateInterval = profile.options?.updateInterval;
|
||||
if (updateInterval != null &&
|
||||
updateInterval <= DateTime.now().difference(profile.lastUpdate)) {
|
||||
await ref
|
||||
.read(profileRepositoryProvider)
|
||||
.requireValue
|
||||
.updateSubscription(profile)
|
||||
.mapLeft(
|
||||
(l) => loggy.debug("error updating profile [${profile.id}]", l),
|
||||
)
|
||||
.map(
|
||||
(_) =>
|
||||
loggy.debug("profile [${profile.id}] updated successfully"),
|
||||
)
|
||||
.run();
|
||||
} else {
|
||||
loggy.debug(
|
||||
"skipping profile [${profile.id}] update. last successful update: [${profile.lastUpdate}] - interval: [${profile.options?.updateInterval}]",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await ref
|
||||
.read(sharedPreferencesProvider)
|
||||
.setString(prefKey, DateTime.now().toIso8601String());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_repository.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profiles_overview_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ProfilesOverviewSortNotifier extends _$ProfilesOverviewSortNotifier
|
||||
with AppLogger {
|
||||
@override
|
||||
({ProfilesSort by, SortMode mode}) build() {
|
||||
return (by: ProfilesSort.lastUpdate, mode: SortMode.descending);
|
||||
}
|
||||
|
||||
void changeSort(ProfilesSort sortBy) =>
|
||||
state = (by: sortBy, mode: state.mode);
|
||||
|
||||
void toggleMode() => state = (
|
||||
by: state.by,
|
||||
mode: state.mode == SortMode.ascending
|
||||
? SortMode.descending
|
||||
: SortMode.ascending
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ProfilesOverviewNotifier extends _$ProfilesOverviewNotifier
|
||||
with AppLogger {
|
||||
@override
|
||||
Stream<List<ProfileEntity>> build() {
|
||||
final sort = ref.watch(profilesOverviewSortNotifierProvider);
|
||||
return _profilesRepo
|
||||
.watchAll(sort: sort.by, sortMode: sort.mode)
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
|
||||
ProfileRepository get _profilesRepo =>
|
||||
ref.read(profileRepositoryProvider).requireValue;
|
||||
|
||||
Future<Unit> selectActiveProfile(String id) async {
|
||||
loggy.debug('changing active profile to: [$id]');
|
||||
return _profilesRepo.setAsActive(id).getOrElse((err) {
|
||||
loggy.warning('failed to set [$id] as active profile', err);
|
||||
throw err;
|
||||
}).run();
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(ProfileEntity profile) async {
|
||||
loggy.debug('deleting profile: ${profile.name}');
|
||||
await _profilesRepo.deleteById(profile.id).match(
|
||||
(err) {
|
||||
loggy.warning('failed to delete profile', err);
|
||||
throw err;
|
||||
},
|
||||
(_) {
|
||||
loggy.info(
|
||||
'successfully deleted profile, was active? [${profile.active}]',
|
||||
);
|
||||
return unit;
|
||||
},
|
||||
).run();
|
||||
}
|
||||
|
||||
Future<void> exportConfigToClipboard(ProfileEntity profile) async {
|
||||
await ref.read(coreFacadeProvider).generateConfig(profile.id).match(
|
||||
(err) {
|
||||
loggy.warning('error generating config', err);
|
||||
throw err;
|
||||
},
|
||||
(configJson) async {
|
||||
await Clipboard.setData(ClipboardData(text: configJson));
|
||||
},
|
||||
).run();
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/enums.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/profile_tile.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
|
||||
import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart';
|
||||
import 'package:hiddify/features/profile/widget/profile_tile.dart';
|
||||
import 'package:hiddify/utils/placeholders.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class ProfilesModal extends HookConsumerWidget {
|
||||
const ProfilesModal({
|
||||
class ProfilesOverviewModal extends HookConsumerWidget {
|
||||
const ProfilesOverviewModal({
|
||||
super.key,
|
||||
this.scrollController,
|
||||
});
|
||||
@@ -21,7 +20,7 @@ class ProfilesModal extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final asyncProfiles = ref.watch(profilesNotifierProvider);
|
||||
final asyncProfiles = ref.watch(profilesOverviewNotifierProvider);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -85,12 +84,14 @@ class ProfilesSortModal extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final sortNotifier =
|
||||
ref.watch(profilesOverviewSortNotifierProvider.notifier);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(t.general.sortBy),
|
||||
content: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final sort = ref.watch(profilesSortNotifierProvider);
|
||||
final sort = ref.watch(profilesOverviewSortNotifierProvider);
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -104,13 +105,9 @@ class ProfilesSortModal extends HookConsumerWidget {
|
||||
title: Text(e.present(t)),
|
||||
onTap: () {
|
||||
if (selected) {
|
||||
ref
|
||||
.read(profilesSortNotifierProvider.notifier)
|
||||
.toggleMode();
|
||||
sortNotifier.toggleMode();
|
||||
} else {
|
||||
ref
|
||||
.read(profilesSortNotifierProvider.notifier)
|
||||
.changeSort(e);
|
||||
sortNotifier.changeSort(e);
|
||||
}
|
||||
},
|
||||
selected: selected,
|
||||
@@ -118,9 +115,7 @@ class ProfilesSortModal extends HookConsumerWidget {
|
||||
trailing: selected
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(profilesSortNotifierProvider.notifier)
|
||||
.toggleMode();
|
||||
sortNotifier.toggleMode();
|
||||
},
|
||||
icon: AnimatedRotation(
|
||||
turns: arrowTurn,
|
||||
@@ -7,10 +7,11 @@ import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/confirmation_dialogs.dart';
|
||||
import 'package:hiddify/features/common/qr_code_dialog.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/features/profile/notifier/profile_notifier.dart';
|
||||
import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:percent_indicator/percent_indicator.dart';
|
||||
@@ -22,7 +23,7 @@ class ProfileTile extends HookConsumerWidget {
|
||||
this.isMain = false,
|
||||
});
|
||||
|
||||
final Profile profile;
|
||||
final ProfileEntity profile;
|
||||
|
||||
/// home screen active profile card
|
||||
final bool isMain;
|
||||
@@ -42,7 +43,7 @@ class ProfileTile extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final subInfo = switch (profile) {
|
||||
RemoteProfile(:final subInfo) => subInfo,
|
||||
RemoteProfileEntity(:final subInfo) => subInfo,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -65,7 +66,7 @@ class ProfileTile extends HookConsumerWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (profile is RemoteProfile || !isMain) ...[
|
||||
if (profile is RemoteProfileEntity || !isMain) ...[
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Semantics(
|
||||
@@ -95,7 +96,7 @@ class ProfileTile extends HookConsumerWidget {
|
||||
if (profile.active) return;
|
||||
selectActiveMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.read(profilesOverviewNotifierProvider.notifier)
|
||||
.selectActiveProfile(profile.id),
|
||||
);
|
||||
}
|
||||
@@ -173,39 +174,27 @@ class ProfileTile extends HookConsumerWidget {
|
||||
class ProfileActionButton extends HookConsumerWidget {
|
||||
const ProfileActionButton(this.profile, this.showAllActions, {super.key});
|
||||
|
||||
final Profile profile;
|
||||
final ProfileEntity profile;
|
||||
final bool showAllActions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final updateProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomAlertDialog.fromErr(
|
||||
t.presentError(err, action: t.profile.update.failureMsg),
|
||||
).show(context);
|
||||
},
|
||||
initialOnSuccess: () =>
|
||||
CustomToast.success(t.profile.update.successMsg).show(context),
|
||||
);
|
||||
|
||||
if (profile case RemoteProfile() when !showAllActions) {
|
||||
if (profile case RemoteProfileEntity() when !showAllActions) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: !updateProfileMutation.state.isInProgress,
|
||||
enabled: !ref.watch(updateProfileProvider(profile.id)).isLoading,
|
||||
child: Tooltip(
|
||||
message: t.profile.update.tooltip,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (updateProfileMutation.state.isInProgress) {
|
||||
if (ref.read(updateProfileProvider(profile.id)).isLoading) {
|
||||
return;
|
||||
}
|
||||
updateProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.updateProfile(profile as RemoteProfile),
|
||||
);
|
||||
ref
|
||||
.read(updateProfileProvider(profile.id).notifier)
|
||||
.updateProfile(profile as RemoteProfileEntity);
|
||||
},
|
||||
child: const Icon(Icons.update),
|
||||
),
|
||||
@@ -239,7 +228,7 @@ class ProfileActionButton extends HookConsumerWidget {
|
||||
class ProfileActionsMenu extends HookConsumerWidget {
|
||||
const ProfileActionsMenu(this.profile, this.builder, {super.key, this.child});
|
||||
|
||||
final Profile profile;
|
||||
final ProfileEntity profile;
|
||||
final MenuAnchorChildBuilder builder;
|
||||
final Widget? child;
|
||||
|
||||
@@ -247,15 +236,6 @@ class ProfileActionsMenu extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final updateProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomAlertDialog.fromErr(
|
||||
t.presentError(err, action: t.profile.update.failureMsg),
|
||||
).show(context);
|
||||
},
|
||||
initialOnSuccess: () =>
|
||||
CustomToast.success(t.profile.update.successMsg).show(context),
|
||||
);
|
||||
final exportConfigMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentShortError(err)).show(context);
|
||||
@@ -273,24 +253,22 @@ class ProfileActionsMenu extends HookConsumerWidget {
|
||||
return MenuAnchor(
|
||||
builder: builder,
|
||||
menuChildren: [
|
||||
if (profile case RemoteProfile())
|
||||
if (profile case RemoteProfileEntity())
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.update),
|
||||
child: Text(t.profile.update.buttonTxt),
|
||||
onPressed: () {
|
||||
if (updateProfileMutation.state.isInProgress) {
|
||||
if (ref.read(updateProfileProvider(profile.id)).isLoading) {
|
||||
return;
|
||||
}
|
||||
updateProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.updateProfile(profile as RemoteProfile),
|
||||
);
|
||||
ref
|
||||
.read(updateProfileProvider(profile.id).notifier)
|
||||
.updateProfile(profile as RemoteProfileEntity);
|
||||
},
|
||||
),
|
||||
SubmenuButton(
|
||||
menuChildren: [
|
||||
if (profile case RemoteProfile(:final url, :final name)) ...[
|
||||
if (profile case RemoteProfileEntity(:final url, :final name)) ...[
|
||||
MenuItemButton(
|
||||
child: Text(t.profile.share.exportSubLinkToClipboard),
|
||||
onPressed: () async {
|
||||
@@ -325,7 +303,7 @@ class ProfileActionsMenu extends HookConsumerWidget {
|
||||
}
|
||||
exportConfigMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.read(profilesOverviewNotifierProvider.notifier)
|
||||
.exportConfigToClipboard(profile),
|
||||
);
|
||||
},
|
||||
@@ -356,7 +334,7 @@ class ProfileActionsMenu extends HookConsumerWidget {
|
||||
if (deleteConfirmed) {
|
||||
deleteProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.read(profilesOverviewNotifierProvider.notifier)
|
||||
.deleteProfile(profile),
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export 'profile_detail_notifier.dart';
|
||||
export 'profile_detail_state.dart';
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'profile_detail_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ProfileDetailState with _$ProfileDetailState {
|
||||
const ProfileDetailState._();
|
||||
|
||||
const factory ProfileDetailState({
|
||||
required Profile profile,
|
||||
@Default(false) bool isEditing,
|
||||
@Default(false) bool showErrorMessages,
|
||||
@Default(MutationState.initial()) MutationState<ProfileFailure> save,
|
||||
@Default(MutationState.initial()) MutationState<ProfileFailure> update,
|
||||
@Default(MutationState.initial()) MutationState<ProfileFailure> delete,
|
||||
}) = _ProfileDetailState;
|
||||
|
||||
bool get isBusy =>
|
||||
save.isInProgress || delete.isInProgress || update.isInProgress;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export 'profile_detail_page.dart';
|
||||
@@ -1,2 +0,0 @@
|
||||
export 'profiles_notifier.dart';
|
||||
export 'profiles_update_notifier.dart';
|
||||
@@ -1,140 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/enums.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/connectivity/connectivity_controller.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profiles_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ProfilesSortNotifier extends _$ProfilesSortNotifier with AppLogger {
|
||||
@override
|
||||
({ProfilesSort by, SortMode mode}) build() {
|
||||
return (by: ProfilesSort.lastUpdate, mode: SortMode.descending);
|
||||
}
|
||||
|
||||
void changeSort(ProfilesSort sortBy) =>
|
||||
state = (by: sortBy, mode: state.mode);
|
||||
|
||||
void toggleMode() => state = (
|
||||
by: state.by,
|
||||
mode: state.mode == SortMode.ascending
|
||||
? SortMode.descending
|
||||
: SortMode.ascending
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
|
||||
@override
|
||||
Stream<List<Profile>> build() {
|
||||
final sort = ref.watch(profilesSortNotifierProvider);
|
||||
return _profilesRepo
|
||||
.watchAll(sort: sort.by, mode: sort.mode)
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
|
||||
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider);
|
||||
|
||||
Future<Unit> selectActiveProfile(String id) async {
|
||||
loggy.debug('changing active profile to: [$id]');
|
||||
return _profilesRepo.setAsActive(id).getOrElse((err) {
|
||||
loggy.warning('failed to set [$id] as active profile', err);
|
||||
throw err;
|
||||
}).run();
|
||||
}
|
||||
|
||||
Future<Unit> addProfile(String rawInput) async {
|
||||
final activeProfile = await ref.read(activeProfileProvider.future);
|
||||
final markAsActive =
|
||||
activeProfile == null || ref.read(markNewProfileActiveProvider);
|
||||
final TaskEither<ProfileFailure, Unit> task;
|
||||
if (LinkParser.parse(rawInput) case (final link)?) {
|
||||
loggy.debug("adding profile, url: [${link.url}]");
|
||||
task = ref
|
||||
.read(profilesRepositoryProvider)
|
||||
.addByUrl(link.url, markAsActive: markAsActive);
|
||||
} else if (LinkParser.protocol(rawInput) case (final parsed)?) {
|
||||
loggy.debug("adding profile, content");
|
||||
task = ref.read(profilesRepositoryProvider).addByContent(
|
||||
parsed.content,
|
||||
name: parsed.name,
|
||||
markAsActive: markAsActive,
|
||||
);
|
||||
} else {
|
||||
loggy.debug("invalid content");
|
||||
throw const ProfileInvalidUrlFailure();
|
||||
}
|
||||
return task.match(
|
||||
(err) {
|
||||
loggy.warning("failed to add profile", err);
|
||||
throw err;
|
||||
},
|
||||
(_) {
|
||||
loggy.info(
|
||||
"successfully added profile, mark as active? [$markAsActive]",
|
||||
);
|
||||
return unit;
|
||||
},
|
||||
).run();
|
||||
}
|
||||
|
||||
Future<Unit?> updateProfile(RemoteProfile profile) async {
|
||||
loggy.debug("updating profile");
|
||||
return await ref.read(profilesRepositoryProvider).update(profile).match(
|
||||
(err) {
|
||||
loggy.warning("failed to update profile", err);
|
||||
throw err;
|
||||
},
|
||||
(_) async {
|
||||
loggy.info(
|
||||
'successfully updated profile, was active? [${profile.active}]',
|
||||
);
|
||||
|
||||
await ref.read(activeProfileProvider.future).then((active) async {
|
||||
if (active != null && active.id == profile.id) {
|
||||
await ref
|
||||
.read(connectivityControllerProvider.notifier)
|
||||
.reconnect(profile.id);
|
||||
}
|
||||
});
|
||||
return unit;
|
||||
},
|
||||
).run();
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(Profile profile) async {
|
||||
loggy.debug('deleting profile: ${profile.name}');
|
||||
await _profilesRepo.delete(profile.id).match(
|
||||
(err) {
|
||||
loggy.warning('failed to delete profile', err);
|
||||
throw err;
|
||||
},
|
||||
(_) {
|
||||
loggy.info(
|
||||
'successfully deleted profile, was active? [${profile.active}]',
|
||||
);
|
||||
return unit;
|
||||
},
|
||||
).run();
|
||||
}
|
||||
|
||||
Future<void> exportConfigToClipboard(Profile profile) async {
|
||||
await ref.read(coreFacadeProvider).generateConfig(profile.id).match(
|
||||
(err) {
|
||||
loggy.warning('error generating config', err);
|
||||
throw err;
|
||||
},
|
||||
(configJson) async {
|
||||
await Clipboard.setData(ClipboardData(text: configJson));
|
||||
},
|
||||
).run();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profiles_update_notifier.g.dart';
|
||||
|
||||
typedef ProfileUpdateResult = ({
|
||||
String name,
|
||||
Either<ProfileFailure, Unit> failureOrSuccess
|
||||
});
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger {
|
||||
@override
|
||||
Stream<ProfileUpdateResult> build() {
|
||||
_schedule();
|
||||
return const Stream.empty();
|
||||
}
|
||||
|
||||
Future<void> _schedule() async {
|
||||
loggy.debug("scheduling profiles update worker");
|
||||
return ref.read(cronServiceProvider).schedule(
|
||||
key: 'profiles_update',
|
||||
duration: const Duration(minutes: 10),
|
||||
callback: () async {
|
||||
final failureOrProfiles =
|
||||
await ref.read(profilesRepositoryProvider).watchAll().first;
|
||||
if (failureOrProfiles case Right(value: final profiles)) {
|
||||
for (final profile in profiles) {
|
||||
if (profile case RemoteProfile()) {
|
||||
loggy.debug("checking profile: [${profile.name}]");
|
||||
final updateInterval = profile.options?.updateInterval;
|
||||
if (updateInterval != null &&
|
||||
updateInterval <=
|
||||
DateTime.now().difference(profile.lastUpdate)) {
|
||||
final failureOrSuccess = await ref
|
||||
.read(profilesRepositoryProvider)
|
||||
.update(profile)
|
||||
.run();
|
||||
state = AsyncData(
|
||||
(name: profile.name, failureOrSuccess: failureOrSuccess),
|
||||
);
|
||||
} else {
|
||||
loggy.debug("skipping profile: [${profile.name}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export 'add_profile_modal.dart';
|
||||
export 'profiles_modal.dart';
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
import 'package:neat_periodic_task/neat_periodic_task.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _cronKeyPrefix = "cron_";
|
||||
|
||||
typedef Job<T> = (
|
||||
String key,
|
||||
Duration duration,
|
||||
FutureOr<T?> Function() callback,
|
||||
);
|
||||
|
||||
class CronService with InfraLogger {
|
||||
CronService(this.prefs);
|
||||
|
||||
final SharedPreferences prefs;
|
||||
|
||||
NeatPeriodicTaskScheduler? _scheduler;
|
||||
Map<String, Job> jobs = {};
|
||||
|
||||
void schedule<T>({
|
||||
required String key,
|
||||
required Duration duration,
|
||||
required FutureOr<T?> Function() callback,
|
||||
}) {
|
||||
loggy.debug("scheduling [$key]");
|
||||
jobs[key] = (key, duration, callback);
|
||||
}
|
||||
|
||||
Future<void> run(Job job) async {
|
||||
final key = job.$1;
|
||||
final prefKey = "$_cronKeyPrefix$key";
|
||||
final previousRunTime = DateTime.tryParse(prefs.getString(prefKey) ?? "");
|
||||
loggy.debug(
|
||||
"[$key] > ${previousRunTime == null ? "first run" : "previous run on [$previousRunTime]"}",
|
||||
);
|
||||
|
||||
if (previousRunTime != null &&
|
||||
previousRunTime.add(job.$2) > DateTime.now()) {
|
||||
loggy.debug("[$key] > didn't meet criteria");
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await job.$3();
|
||||
await prefs.setString(prefKey, DateTime.now().toIso8601String());
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> startScheduler() async {
|
||||
loggy.debug("starting job scheduler");
|
||||
await _scheduler?.stop();
|
||||
int runCount = 0;
|
||||
_scheduler = NeatPeriodicTaskScheduler(
|
||||
name: "cron job scheduler",
|
||||
interval: const Duration(minutes: 10),
|
||||
timeout: const Duration(minutes: 5),
|
||||
minCycle: const Duration(minutes: 2),
|
||||
task: () {
|
||||
loggy.debug("in run ${runCount++}");
|
||||
return Future.wait(jobs.values.map(run));
|
||||
},
|
||||
);
|
||||
_scheduler!.start();
|
||||
}
|
||||
|
||||
Future<void> stopScheduler() async {
|
||||
loggy.debug("stopping job scheduler");
|
||||
return _scheduler?.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hiddify/domain/constants.dart';
|
||||
import 'package:hiddify/services/platform_services.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -20,8 +19,6 @@ class FilesEditorService with InfraLogger {
|
||||
late final Directories dirs;
|
||||
|
||||
Directory get workingDir => dirs.workingDir;
|
||||
Directory get configsDir =>
|
||||
Directory(p.join(workingDir.path, Constants.configsFolderName));
|
||||
Directory get logsDir => dirs.workingDir;
|
||||
|
||||
File get appLogsFile => File(p.join(logsDir.path, "app.log"));
|
||||
@@ -43,9 +40,6 @@ class FilesEditorService with InfraLogger {
|
||||
if (!await dirs.workingDir.exists()) {
|
||||
await dirs.workingDir.create(recursive: true);
|
||||
}
|
||||
if (!await configsDir.exists()) {
|
||||
await configsDir.create(recursive: true);
|
||||
}
|
||||
|
||||
if (await appLogsFile.exists()) {
|
||||
await appLogsFile.writeAsString("");
|
||||
@@ -68,14 +62,4 @@ class FilesEditorService with InfraLogger {
|
||||
}
|
||||
return getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
String configPath(String fileName) {
|
||||
return p.join(configsDir.path, "$fileName.json");
|
||||
}
|
||||
|
||||
String tempConfigPath(String fileName) => configPath("temp_$fileName");
|
||||
|
||||
Future<void> deleteConfig(String fileName) {
|
||||
return File(configPath(fileName)).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/services/cron_service.dart';
|
||||
import 'package:hiddify/services/files_editor_service.dart';
|
||||
import 'package:hiddify/services/platform_services.dart';
|
||||
import 'package:hiddify/services/singbox/singbox_service.dart';
|
||||
@@ -17,10 +15,3 @@ SingboxService singboxService(SingboxServiceRef ref) => SingboxService();
|
||||
@Riverpod(keepAlive: true)
|
||||
PlatformServices platformServices(PlatformServicesRef ref) =>
|
||||
PlatformServices();
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
CronService cronService(CronServiceRef ref) {
|
||||
final service = CronService(ref.watch(sharedPreferencesProvider));
|
||||
ref.onDispose(() => service.stopScheduler());
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_parser.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
|
||||
void main() {
|
||||
const validBaseUrl = "https://example.com/configurations/user1/filename.yaml";
|
||||
@@ -8,14 +9,26 @@ void main() {
|
||||
const validSupportUrl = "https://example.com/support";
|
||||
|
||||
group(
|
||||
"profile fromResponse",
|
||||
"parse",
|
||||
() {
|
||||
test(
|
||||
"with no additional metadata",
|
||||
"url with file extension, no headers",
|
||||
() {
|
||||
final profile = Profile.fromResponse(validExtendedUrl, {});
|
||||
final profile = ProfileParser.parse(validBaseUrl, {});
|
||||
|
||||
expect(profile.name, equals("filename"));
|
||||
expect(profile.url, equals(validBaseUrl));
|
||||
expect(profile.options, isNull);
|
||||
expect(profile.subInfo, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"url with url, no headers",
|
||||
() {
|
||||
final profile = ProfileParser.parse(validExtendedUrl, {});
|
||||
|
||||
expect(profile.name, equals("b"));
|
||||
expect(profile.url, equals(validExtendedUrl));
|
||||
expect(profile.options, isNull);
|
||||
expect(profile.subInfo, isNull);
|
||||
@@ -23,7 +36,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
"with all metadata",
|
||||
"with base64 profile-title header",
|
||||
() {
|
||||
final headers = <String, List<String>>{
|
||||
"profile-title": ["base64:ZXhhbXBsZVRpdGxl"],
|
||||
@@ -34,7 +47,7 @@ void main() {
|
||||
"profile-web-page-url": [validBaseUrl],
|
||||
"support-url": [validSupportUrl],
|
||||
};
|
||||
final profile = Profile.fromResponse(validExtendedUrl, headers);
|
||||
final profile = ProfileParser.parse(validExtendedUrl, headers);
|
||||
|
||||
expect(profile.name, equals("exampleTitle"));
|
||||
expect(profile.url, equals(validExtendedUrl));
|
||||
Reference in New Issue
Block a user