diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index ea6c53c1..c14409b8 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -34,6 +34,7 @@ "fromClipboard": "add from clipboard", "scanQr": "Scan QR code", "manually": "add manually", + "addingProfileMsg": "adding profile", "invalidUrlMsg": "unexpected url" }, "update": { diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 1e51da29..8b0a48f3 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -34,6 +34,7 @@ "fromClipboard": "افزودن از کلیپ‌بورد", "scanQr": "اسکن QR کد", "manually": "افزودن دستی", + "addingProfileMsg": "در حال افزودن پروفایل", "invalidUrlMsg": "لینک نامعتبر" }, "update": { diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 04d4d219..eb1dd515 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -13,16 +13,14 @@ GoRouter router(RouterRef ref) { deepLinkServiceProvider, (_, next) async { if (next case AsyncData(value: final link?)) { - await ref.state.push( - NewProfileRoute(url: link.url, name: link.name).location, - ); + await ref.state.push(AddProfileRoute(url: link.url).location); } }, ); final initialLink = deepLink.read(); String initialLocation = HomeRoute.path; if (initialLink case AsyncData(value: final link?)) { - initialLocation = NewProfileRoute(url: link.url, name: link.name).location; + initialLocation = AddProfileRoute(url: link.url).location; } return GoRouter( diff --git a/lib/core/router/routes/shared_routes.dart b/lib/core/router/routes/shared_routes.dart index 0ecd1a4b..12c07e4b 100644 --- a/lib/core/router/routes/shared_routes.dart +++ b/lib/core/router/routes/shared_routes.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:hiddify/features/common/common.dart'; import 'package:hiddify/features/home/view/view.dart'; import 'package:hiddify/features/profile_detail/view/view.dart'; import 'package:hiddify/features/profiles/view/view.dart'; @@ -35,8 +34,9 @@ class ProxiesRoute extends GoRouteData { @TypedGoRoute(path: AddProfileRoute.path) class AddProfileRoute extends GoRouteData { - const AddProfileRoute(); + const AddProfileRoute({this.url}); static const path = '/add'; + final String? url; static final GlobalKey $parentNavigatorKey = rootNavigatorKey; @@ -44,7 +44,10 @@ class AddProfileRoute extends GoRouteData { Page buildPage(BuildContext context, GoRouterState state) { return BottomSheetPage( fixed: true, - builder: (controller) => AddProfileModal(scrollController: controller), + builder: (controller) => AddProfileModal( + url: url, + scrollController: controller, + ), ); } } diff --git a/lib/data/repository/profiles_repository_impl.dart b/lib/data/repository/profiles_repository_impl.dart index 179b9264..f3176ca6 100644 --- a/lib/data/repository/profiles_repository_impl.dart +++ b/lib/data/repository/profiles_repository_impl.dart @@ -9,6 +9,7 @@ import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:meta/meta.dart'; +import 'package:uuid/uuid.dart'; class ProfilesRepositoryImpl with ExceptionHandler, InfraLogger @@ -55,16 +56,45 @@ class ProfilesRepositoryImpl .handleExceptions(ProfileUnexpectedFailure.new); } + @override + TaskEither addByUrl( + String url, { + bool markAsActive = false, + }) { + return exceptionHandler( + () async { + final profileId = const Uuid().v4(); + return fetch(url, profileId) + .flatMap( + (profile) => TaskEither( + () async { + await profilesDao.create( + profile.copyWith( + id: profileId, + active: markAsActive, + ), + ); + return right(unit); + }, + ), + ) + .run(); + }, + ProfileUnexpectedFailure.new, + ); + } + @override TaskEither add(Profile baseProfile) { return exceptionHandler( () async { return fetch(baseProfile.url, baseProfile.id) .flatMap( - (subInfo) => TaskEither(() async { + (remoteProfile) => TaskEither(() async { await profilesDao.create( baseProfile.copyWith( - subInfo: subInfo, + subInfo: remoteProfile.subInfo, + extra: remoteProfile.extra, lastUpdate: DateTime.now(), ), ); @@ -83,10 +113,11 @@ class ProfilesRepositoryImpl () async { return fetch(baseProfile.url, baseProfile.id) .flatMap( - (subInfo) => TaskEither(() async { + (remoteProfile) => TaskEither(() async { await profilesDao.edit( baseProfile.copyWith( - subInfo: subInfo, + subInfo: remoteProfile.subInfo, + extra: remoteProfile.extra, lastUpdate: DateTime.now(), ), ); @@ -123,7 +154,7 @@ class ProfilesRepositoryImpl } @visibleForTesting - TaskEither fetch( + TaskEither fetch( String url, String fileName, ) { @@ -143,12 +174,8 @@ class ProfilesRepositoryImpl await File(path).delete(); return left(const ProfileFailure.invalidConfig()); } - final subInfoString = - response.headers.map['subscription-userinfo']?.single; - final subInfo = subInfoString != null - ? SubscriptionInfo.fromResponseHeader(subInfoString) - : null; - return right(subInfo); + final profile = Profile.fromResponse(url, response.headers.map); + return right(profile); }, ); } diff --git a/lib/domain/profiles/profile.dart b/lib/domain/profiles/profile.dart index 9a16e096..489e45cc 100644 --- a/lib/domain/profiles/profile.dart +++ b/lib/domain/profiles/profile.dart @@ -72,7 +72,7 @@ class Profile with _$Profile { return Profile( id: const Uuid().v4(), active: false, - name: title, + name: title.isBlank ? "Remote Profile" : title, url: url, lastUpdate: DateTime.now(), options: options, diff --git a/lib/domain/profiles/profiles_repository.dart b/lib/domain/profiles/profiles_repository.dart index dfb56dc7..ca9dab72 100644 --- a/lib/domain/profiles/profiles_repository.dart +++ b/lib/domain/profiles/profiles_repository.dart @@ -10,6 +10,11 @@ abstract class ProfilesRepository { Stream>> watchAll(); + TaskEither addByUrl( + String url, { + bool markAsActive = false, + }); + TaskEither add(Profile baseProfile); TaskEither update(Profile baseProfile); diff --git a/lib/features/common/add_profile_modal.dart b/lib/features/common/add_profile_modal.dart deleted file mode 100644 index 6d653695..00000000 --- a/lib/features/common/add_profile_modal.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/features/common/qr_code_scanner_screen.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:recase/recase.dart'; - -class AddProfileModal extends HookConsumerWidget { - const AddProfileModal({ - super.key, - this.scrollController, - }); - - final ScrollController? scrollController; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); - - const buttonsPadding = 24.0; - const buttonsGap = 16.0; - - return SingleChildScrollView( - controller: scrollController, - child: Column( - children: [ - LayoutBuilder( - builder: (context, constraints) { - // temporary solution, aspect ratio widget relies on height and in a row there no height! - final buttonWidth = constraints.maxWidth / 2 - - (buttonsPadding + (buttonsGap / 2)); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: buttonsPadding), - child: Row( - children: [ - _Button( - label: t.profile.add.fromClipboard.sentenceCase, - icon: Icons.content_paste, - size: buttonWidth, - onTap: () async { - final captureResult = - await Clipboard.getData(Clipboard.kTextPlain); - final link = - LinkParser.simple(captureResult?.text ?? ''); - if (link != null && context.mounted) { - context.pop(); - await NewProfileRoute(url: link.url, name: link.name) - .push(context); - } else { - CustomToast.error( - t.profile.add.invalidUrlMsg.sentenceCase, - ).show(context); - } - }, - ), - const Gap(buttonsGap), - if (!PlatformUtils.isDesktop) - _Button( - label: t.profile.add.scanQr, - icon: Icons.qr_code_scanner, - size: buttonWidth, - onTap: () async { - final captureResult = - await const QRCodeScannerScreen().open(context); - if (captureResult == null) return; - final link = LinkParser.simple(captureResult); - if (link != null && context.mounted) { - context.pop(); - await NewProfileRoute( - url: link.url, - name: link.name, - ).push(context); - } else { - CustomToast.error( - t.profile.add.invalidUrlMsg.sentenceCase, - ).show(context); - } - }, - ) - else - _Button( - label: t.profile.add.manually.sentenceCase, - icon: Icons.add, - size: buttonWidth, - onTap: () async { - context.pop(); - await const NewProfileRoute().push(context); - }, - ), - ], - ), - ); - }, - ), - if (!PlatformUtils.isDesktop) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: buttonsPadding, - vertical: 16, - ), - child: SizedBox( - height: 36, - child: Material( - elevation: 8, - color: Theme.of(context).colorScheme.surface, - surfaceTintColor: Theme.of(context).colorScheme.surfaceTint, - shadowColor: Colors.transparent, - borderRadius: BorderRadius.circular(8), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () async { - context.pop(); - await const NewProfileRoute().push(context); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add, - color: Theme.of(context).colorScheme.primary, - ), - const Gap(8), - Text( - t.profile.add.manually.sentenceCase, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - ), - ), - ), - const Gap(24), - ], - ), - ); - } -} - -class _Button extends StatelessWidget { - const _Button({ - required this.label, - required this.icon, - required this.size, - required this.onTap, - }); - - final String label; - final IconData icon; - final double size; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final color = Theme.of(context).colorScheme.primary; - - return SizedBox( - width: size, - height: size, - child: Material( - elevation: 8, - color: Theme.of(context).colorScheme.surface, - surfaceTintColor: Theme.of(context).colorScheme.surfaceTint, - shadowColor: Colors.transparent, - borderRadius: BorderRadius.circular(8), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: size / 3, - color: color, - ), - const Gap(16), - Flexible( - child: Text( - label, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith(color: color), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/common/common.dart b/lib/features/common/common.dart index 39fb5ab3..0f85d1aa 100644 --- a/lib/features/common/common.dart +++ b/lib/features/common/common.dart @@ -1,4 +1,3 @@ -export 'add_profile_modal.dart'; export 'confirmation_dialogs.dart'; export 'custom_app_bar.dart'; export 'profile_tile.dart'; diff --git a/lib/features/common/qr_code_scanner_screen.dart b/lib/features/common/qr_code_scanner_screen.dart index 11fc5dc2..d52175ca 100644 --- a/lib/features/common/qr_code_scanner_screen.dart +++ b/lib/features/common/qr_code_scanner_screen.dart @@ -20,7 +20,7 @@ class QRCodeScannerScreen extends HookConsumerWidget with PresLogger { Widget build(BuildContext context, WidgetRef ref) { final controller = useMemoized( () => MobileScannerController( - detectionSpeed: DetectionSpeed.noDuplicates, + detectionTimeoutMs: 500, formats: [BarcodeFormat.qrCode], ), ); diff --git a/lib/features/profiles/notifier/profiles_notifier.dart b/lib/features/profiles/notifier/profiles_notifier.dart index 73d76b5b..7a862b43 100644 --- a/lib/features/profiles/notifier/profiles_notifier.dart +++ b/lib/features/profiles/notifier/profiles_notifier.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:fpdart/fpdart.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; +import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -27,6 +28,18 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger { }).run(); } + Future addProfile(String url) async { + final activeProfile = await ref.read(activeProfileProvider.future); + loggy.debug("adding profile, url: [$url]"); + return ref + .read(profilesRepositoryProvider) + .addByUrl(url, markAsActive: activeProfile == null) + .getOrElse((l) { + loggy.warning("failed to add profile: $l"); + throw l; + }).run(); + } + Future updateProfile(Profile profile) async { loggy.debug("updating profile"); return ref diff --git a/lib/features/profiles/view/add_profile_modal.dart b/lib/features/profiles/view/add_profile_modal.dart new file mode 100644 index 00000000..c0f36d33 --- /dev/null +++ b/lib/features/profiles/view/add_profile_modal.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/router/router.dart'; +import 'package:hiddify/domain/failures.dart'; +import 'package:hiddify/features/common/qr_code_scanner_screen.dart'; +import 'package:hiddify/features/profiles/notifier/notifier.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:recase/recase.dart'; + +class AddProfileModal extends HookConsumerWidget { + const AddProfileModal({ + super.key, + this.url, + this.scrollController, + }); + + final String? url; + final ScrollController? scrollController; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + + final mutationTriggered = useState(false); + final addProfileMutation = useMutation( + initialOnFailure: (err) { + mutationTriggered.value = false; + CustomToast.error(t.presentError(err)).show(context); + }, + initialOnSuccess: () { + CustomToast.success(t.profile.save.successMsg.sentenceCase) + .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!), + ); + } + }); + + final theme = Theme.of(context); + const buttonsPadding = 24.0; + const buttonsGap = 16.0; + + return SingleChildScrollView( + controller: scrollController, + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + child: LayoutBuilder( + builder: (context, constraints) { + // temporary solution, aspect ratio widget relies on height and in a row there no height! + final buttonWidth = + constraints.maxWidth / 2 - (buttonsPadding + (buttonsGap / 2)); + + return AnimatedCrossFade( + firstChild: SizedBox( + height: buttonWidth.clamp(0, 168), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 64), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + t.profile.add.addingProfileMsg.sentenceCase, + style: theme.textTheme.bodySmall, + ), + const Gap(8), + const LinearProgressIndicator( + backgroundColor: Colors.transparent, + ), + ], + ), + ), + ), + secondChild: Column( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: buttonsPadding), + child: Row( + children: [ + _Button( + label: t.profile.add.fromClipboard.sentenceCase, + icon: Icons.content_paste, + size: buttonWidth, + onTap: () async { + final captureResult = + await Clipboard.getData(Clipboard.kTextPlain); + final link = + LinkParser.simple(captureResult?.text ?? ''); + if (link != null && context.mounted) { + if (addProfileMutation.state.isInProgress) return; + mutationTriggered.value = true; + addProfileMutation.setFuture( + ref + .read(profilesNotifierProvider.notifier) + .addProfile(link.url), + ); + } else { + CustomToast.error( + t.profile.add.invalidUrlMsg.sentenceCase, + ).show(context); + } + }, + ), + const Gap(buttonsGap), + if (!PlatformUtils.isDesktop) + _Button( + label: t.profile.add.scanQr, + icon: Icons.qr_code_scanner, + size: buttonWidth, + onTap: () async { + final captureResult = + await const QRCodeScannerScreen() + .open(context); + if (captureResult == null) return; + final link = LinkParser.simple(captureResult); + if (link != null && context.mounted) { + if (addProfileMutation.state.isInProgress) { + return; + } + mutationTriggered.value = true; + addProfileMutation.setFuture( + ref + .read(profilesNotifierProvider.notifier) + .addProfile(link.url), + ); + } else { + CustomToast.error( + t.profile.add.invalidUrlMsg.sentenceCase, + ).show(context); + } + }, + ) + else + _Button( + label: t.profile.add.manually.sentenceCase, + icon: Icons.add, + size: buttonWidth, + onTap: () async { + context.pop(); + await const NewProfileRoute().push(context); + }, + ), + ], + ), + ), + if (!PlatformUtils.isDesktop) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: buttonsPadding, + vertical: 16, + ), + child: SizedBox( + height: 36, + child: Material( + elevation: 8, + color: theme.colorScheme.surface, + surfaceTintColor: theme.colorScheme.surfaceTint, + shadowColor: Colors.transparent, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + context.pop(); + await const NewProfileRoute().push(context); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add, + color: theme.colorScheme.primary, + ), + const Gap(8), + Text( + t.profile.add.manually.sentenceCase, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + ), + ), + ), + const Gap(24), + ], + ), + crossFadeState: showProgressIndicator + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 250), + ); + }, + ), + ), + ); + } +} + +class _Button extends StatelessWidget { + const _Button({ + required this.label, + required this.icon, + required this.size, + required this.onTap, + }); + + final String label; + final IconData icon; + final double size; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.primary; + + return SizedBox( + width: size, + height: size, + child: Material( + elevation: 8, + color: theme.colorScheme.surface, + surfaceTintColor: theme.colorScheme.surfaceTint, + shadowColor: Colors.transparent, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: size / 3, + color: color, + ), + const Gap(16), + Flexible( + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith(color: color), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/profiles/view/view.dart b/lib/features/profiles/view/view.dart index fa509805..cb18b1bf 100644 --- a/lib/features/profiles/view/view.dart +++ b/lib/features/profiles/view/view.dart @@ -1 +1,2 @@ +export 'add_profile_modal.dart'; export 'profiles_modal.dart';