Refactor profile addition flow

This commit is contained in:
problematicconsumer
2023-07-26 14:17:11 +03:30
parent cad4e47ee5
commit d741b7a427
13 changed files with 340 additions and 223 deletions

View File

@@ -34,6 +34,7 @@
"fromClipboard": "add from clipboard", "fromClipboard": "add from clipboard",
"scanQr": "Scan QR code", "scanQr": "Scan QR code",
"manually": "add manually", "manually": "add manually",
"addingProfileMsg": "adding profile",
"invalidUrlMsg": "unexpected url" "invalidUrlMsg": "unexpected url"
}, },
"update": { "update": {

View File

@@ -34,6 +34,7 @@
"fromClipboard": "افزودن از کلیپ‌بورد", "fromClipboard": "افزودن از کلیپ‌بورد",
"scanQr": "اسکن QR کد", "scanQr": "اسکن QR کد",
"manually": "افزودن دستی", "manually": "افزودن دستی",
"addingProfileMsg": "در حال افزودن پروفایل",
"invalidUrlMsg": "لینک نامعتبر" "invalidUrlMsg": "لینک نامعتبر"
}, },
"update": { "update": {

View File

@@ -13,16 +13,14 @@ GoRouter router(RouterRef ref) {
deepLinkServiceProvider, deepLinkServiceProvider,
(_, next) async { (_, next) async {
if (next case AsyncData(value: final link?)) { if (next case AsyncData(value: final link?)) {
await ref.state.push( await ref.state.push(AddProfileRoute(url: link.url).location);
NewProfileRoute(url: link.url, name: link.name).location,
);
} }
}, },
); );
final initialLink = deepLink.read(); final initialLink = deepLink.read();
String initialLocation = HomeRoute.path; String initialLocation = HomeRoute.path;
if (initialLink case AsyncData(value: final link?)) { if (initialLink case AsyncData(value: final link?)) {
initialLocation = NewProfileRoute(url: link.url, name: link.name).location; initialLocation = AddProfileRoute(url: link.url).location;
} }
return GoRouter( return GoRouter(

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/home/view/view.dart';
import 'package:hiddify/features/profile_detail/view/view.dart'; import 'package:hiddify/features/profile_detail/view/view.dart';
import 'package:hiddify/features/profiles/view/view.dart'; import 'package:hiddify/features/profiles/view/view.dart';
@@ -35,8 +34,9 @@ class ProxiesRoute extends GoRouteData {
@TypedGoRoute<AddProfileRoute>(path: AddProfileRoute.path) @TypedGoRoute<AddProfileRoute>(path: AddProfileRoute.path)
class AddProfileRoute extends GoRouteData { class AddProfileRoute extends GoRouteData {
const AddProfileRoute(); const AddProfileRoute({this.url});
static const path = '/add'; static const path = '/add';
final String? url;
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
@@ -44,7 +44,10 @@ class AddProfileRoute extends GoRouteData {
Page<void> buildPage(BuildContext context, GoRouterState state) { Page<void> buildPage(BuildContext context, GoRouterState state) {
return BottomSheetPage( return BottomSheetPage(
fixed: true, fixed: true,
builder: (controller) => AddProfileModal(scrollController: controller), builder: (controller) => AddProfileModal(
url: url,
scrollController: controller,
),
); );
} }
} }

View File

@@ -9,6 +9,7 @@ import 'package:hiddify/domain/profiles/profiles.dart';
import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/services/files_editor_service.dart';
import 'package:hiddify/utils/utils.dart'; import 'package:hiddify/utils/utils.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:uuid/uuid.dart';
class ProfilesRepositoryImpl class ProfilesRepositoryImpl
with ExceptionHandler, InfraLogger with ExceptionHandler, InfraLogger
@@ -55,16 +56,45 @@ class ProfilesRepositoryImpl
.handleExceptions(ProfileUnexpectedFailure.new); .handleExceptions(ProfileUnexpectedFailure.new);
} }
@override
TaskEither<ProfileFailure, Unit> 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 @override
TaskEither<ProfileFailure, Unit> add(Profile baseProfile) { TaskEither<ProfileFailure, Unit> add(Profile baseProfile) {
return exceptionHandler( return exceptionHandler(
() async { () async {
return fetch(baseProfile.url, baseProfile.id) return fetch(baseProfile.url, baseProfile.id)
.flatMap( .flatMap(
(subInfo) => TaskEither(() async { (remoteProfile) => TaskEither(() async {
await profilesDao.create( await profilesDao.create(
baseProfile.copyWith( baseProfile.copyWith(
subInfo: subInfo, subInfo: remoteProfile.subInfo,
extra: remoteProfile.extra,
lastUpdate: DateTime.now(), lastUpdate: DateTime.now(),
), ),
); );
@@ -83,10 +113,11 @@ class ProfilesRepositoryImpl
() async { () async {
return fetch(baseProfile.url, baseProfile.id) return fetch(baseProfile.url, baseProfile.id)
.flatMap( .flatMap(
(subInfo) => TaskEither(() async { (remoteProfile) => TaskEither(() async {
await profilesDao.edit( await profilesDao.edit(
baseProfile.copyWith( baseProfile.copyWith(
subInfo: subInfo, subInfo: remoteProfile.subInfo,
extra: remoteProfile.extra,
lastUpdate: DateTime.now(), lastUpdate: DateTime.now(),
), ),
); );
@@ -123,7 +154,7 @@ class ProfilesRepositoryImpl
} }
@visibleForTesting @visibleForTesting
TaskEither<ProfileFailure, SubscriptionInfo?> fetch( TaskEither<ProfileFailure, Profile> fetch(
String url, String url,
String fileName, String fileName,
) { ) {
@@ -143,12 +174,8 @@ class ProfilesRepositoryImpl
await File(path).delete(); await File(path).delete();
return left(const ProfileFailure.invalidConfig()); return left(const ProfileFailure.invalidConfig());
} }
final subInfoString = final profile = Profile.fromResponse(url, response.headers.map);
response.headers.map['subscription-userinfo']?.single; return right(profile);
final subInfo = subInfoString != null
? SubscriptionInfo.fromResponseHeader(subInfoString)
: null;
return right(subInfo);
}, },
); );
} }

View File

@@ -72,7 +72,7 @@ class Profile with _$Profile {
return Profile( return Profile(
id: const Uuid().v4(), id: const Uuid().v4(),
active: false, active: false,
name: title, name: title.isBlank ? "Remote Profile" : title,
url: url, url: url,
lastUpdate: DateTime.now(), lastUpdate: DateTime.now(),
options: options, options: options,

View File

@@ -10,6 +10,11 @@ abstract class ProfilesRepository {
Stream<Either<ProfileFailure, List<Profile>>> watchAll(); Stream<Either<ProfileFailure, List<Profile>>> watchAll();
TaskEither<ProfileFailure, Unit> addByUrl(
String url, {
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> add(Profile baseProfile); TaskEither<ProfileFailure, Unit> add(Profile baseProfile);
TaskEither<ProfileFailure, Unit> update(Profile baseProfile); TaskEither<ProfileFailure, Unit> update(Profile baseProfile);

View File

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

View File

@@ -1,4 +1,3 @@
export 'add_profile_modal.dart';
export 'confirmation_dialogs.dart'; export 'confirmation_dialogs.dart';
export 'custom_app_bar.dart'; export 'custom_app_bar.dart';
export 'profile_tile.dart'; export 'profile_tile.dart';

View File

@@ -20,7 +20,7 @@ class QRCodeScannerScreen extends HookConsumerWidget with PresLogger {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final controller = useMemoized( final controller = useMemoized(
() => MobileScannerController( () => MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates, detectionTimeoutMs: 500,
formats: [BarcodeFormat.qrCode], formats: [BarcodeFormat.qrCode],
), ),
); );

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:fpdart/fpdart.dart'; import 'package:fpdart/fpdart.dart';
import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/data/data_providers.dart';
import 'package:hiddify/domain/profiles/profiles.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:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -27,6 +28,18 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
}).run(); }).run();
} }
Future<Unit> 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<Unit?> updateProfile(Profile profile) async { Future<Unit?> updateProfile(Profile profile) async {
loggy.debug("updating profile"); loggy.debug("updating profile");
return ref return ref

View File

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

View File

@@ -1 +1,2 @@
export 'add_profile_modal.dart';
export 'profiles_modal.dart'; export 'profiles_modal.dart';