Add local profile

This commit is contained in:
problematicconsumer
2023-10-02 18:51:14 +03:30
parent a7e157c036
commit d50541f7a3
26 changed files with 1118 additions and 260 deletions

View File

@@ -1,4 +1,3 @@
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/data/data_providers.dart';
import 'package:hiddify/domain/profiles/profiles.dart';
import 'package:hiddify/utils/utils.dart';
@@ -16,16 +15,4 @@ class ActiveProfile extends _$ActiveProfile with AppLogger {
.watchActiveProfile()
.map((event) => event.getOrElse((l) => throw l));
}
Future<Unit?> updateProfile() async {
if (state case AsyncData(value: final profile?)) {
loggy.debug("updating active profile");
return ref
.read(profilesRepositoryProvider)
.update(profile)
.getOrElse((l) => throw l)
.run();
}
return null;
}
}

View File

@@ -39,7 +39,10 @@ class ProfileTile extends HookConsumerWidget {
},
);
final subInfo = profile.subInfo;
final subInfo = switch (profile) {
RemoteProfile(:final subInfo) => subInfo,
_ => null,
};
final effectiveMargin = isMain
? const EdgeInsets.symmetric(horizontal: 16, vertical: 8)
@@ -60,17 +63,19 @@ class ProfileTile extends HookConsumerWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 48,
child: Semantics(
sortKey: const OrdinalSortKey(1),
child: ProfileActionButton(profile, !isMain),
if (profile is RemoteProfile || !isMain) ...[
SizedBox(
width: 48,
child: Semantics(
sortKey: const OrdinalSortKey(1),
child: ProfileActionButton(profile, !isMain),
),
),
),
VerticalDivider(
width: 1,
color: effectiveOutlineColor,
),
VerticalDivider(
width: 1,
color: effectiveOutlineColor,
),
],
Expanded(
child: Semantics(
button: true,
@@ -177,7 +182,7 @@ class ProfileActionButton extends HookConsumerWidget {
CustomToast.success(t.profile.update.successMsg).show(context),
);
if (!showAllActions) {
if (profile case RemoteProfile() when !showAllActions) {
return Semantics(
button: true,
enabled: !updateProfileMutation.state.isInProgress,
@@ -191,7 +196,7 @@ class ProfileActionButton extends HookConsumerWidget {
updateProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.updateProfile(profile),
.updateProfile(profile as RemoteProfile),
);
},
child: const Icon(Icons.update),
@@ -250,20 +255,21 @@ class ProfileActionsMenu extends HookConsumerWidget {
return MenuAnchor(
builder: builder,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.update),
child: Text(t.profile.update.buttonTxt),
onPressed: () {
if (updateProfileMutation.state.isInProgress) {
return;
}
updateProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.updateProfile(profile),
);
},
),
if (profile case RemoteProfile())
MenuItemButton(
leadingIcon: const Icon(Icons.update),
child: Text(t.profile.update.buttonTxt),
onPressed: () {
if (updateProfileMutation.state.isInProgress) {
return;
}
updateProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.updateProfile(profile as RemoteProfile),
);
},
),
MenuItemButton(
leadingIcon: const Icon(Icons.edit),
child: Text(t.profile.edit.buttonTxt),

View File

@@ -19,7 +19,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
}) async {
if (id == 'new') {
return ProfileDetailState(
profile: Profile(
profile: RemoteProfile(
id: const Uuid().v4(),
active: true,
name: profileName ?? "",
@@ -52,15 +52,20 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
if (state case AsyncData(:final value)) {
state = AsyncData(
value.copyWith(
profile: value.profile.copyWith(
name: name ?? value.profile.name,
url: url ?? value.profile.url,
options: updateInterval == null
? value.profile.options
: updateInterval.fold(
() => null,
(t) => ProfileOptions(updateInterval: Duration(hours: t)),
),
profile: value.profile.map(
remote: (rp) => rp.copyWith(
name: name ?? rp.name,
url: url ?? rp.url,
options: updateInterval == null
? rp.options
: updateInterval.fold(
() => null,
(t) => ProfileOptions(
updateInterval: Duration(hours: t),
),
),
),
local: (lp) => lp.copyWith(name: name ?? lp.name),
),
),
);
@@ -71,24 +76,33 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
if (state case AsyncData(:final value)) {
if (value.save.isInProgress) return;
final profile = value.profile;
loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]',
);
state = AsyncData(value.copyWith(save: const MutationInProgress()));
Either<ProfileFailure, Unit>? failureOrSuccess;
if (profile.name.isBlank || profile.url.isBlank) {
loggy.debug('profile save: invalid arguments');
} else if (value.isEditing) {
if (_originalProfile?.url == profile.url) {
state = AsyncData(value.copyWith(save: const MutationInProgress()));
switch (profile) {
case RemoteProfile():
loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]',
);
if (profile.name.isBlank || profile.url.isBlank) {
loggy.debug('profile save: invalid arguments');
} else if (value.isEditing) {
if (_originalProfile case RemoteProfile(:final url)
when url == profile.url) {
loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.edit(profile).run();
} else {
loggy.debug('updating profile');
failureOrSuccess = await _profilesRepo.update(profile).run();
}
} else {
loggy.debug('adding profile, url: [${profile.url}]');
failureOrSuccess = await _profilesRepo.add(profile).run();
}
case LocalProfile() when value.isEditing:
loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.edit(profile).run();
} else {
loggy.debug('updating profile');
failureOrSuccess = await _profilesRepo.update(profile).run();
}
} else {
loggy.debug('adding profile, url: [${profile.url}]');
failureOrSuccess = await _profilesRepo.add(profile).run();
default:
loggy.warning("local profile can't be added manually");
}
state = AsyncData(
value.copyWith(
@@ -105,12 +119,17 @@ 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()) {
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()));
final failureOrUpdatedProfile = await _profilesRepo
.update(profile)
.update(profile as RemoteProfile)
.flatMap((_) => _profilesRepo.get(id))
.run();
state = AsyncData(

View File

@@ -3,6 +3,7 @@ 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/settings/widgets/widgets.dart';
@@ -93,12 +94,13 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
PopupMenuButton(
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(t.profile.update.buttonTxt),
onTap: () async {
await notifier.updateProfile();
},
),
if (state.profile case RemoteProfile())
PopupMenuItem(
child: Text(t.profile.update.buttonTxt),
onTap: () async {
await notifier.updateProfile();
},
),
PopupMenuItem(
child: Text(t.profile.delete.buttonTxt),
onTap: () async {
@@ -140,52 +142,55 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
hint: t.profile.detailsForm.nameHint,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
if (state.profile
case RemoteProfile(:final url, :final options)) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: CustomTextFormField(
initialValue: url,
onChanged: (value) =>
notifier.setField(url: value),
validator: (value) =>
(value != null && !isUrl(value))
? t.profile.detailsForm.invalidUrlMsg
: null,
label: t.profile.detailsForm.urlLabel,
hint: t.profile.detailsForm.urlHint,
),
),
child: CustomTextFormField(
initialValue: state.profile.url,
onChanged: (value) => notifier.setField(url: value),
validator: (value) =>
(value != null && !isUrl(value))
? t.profile.detailsForm.invalidUrlMsg
: null,
label: t.profile.detailsForm.urlLabel,
hint: t.profile.detailsForm.urlHint,
ListTile(
title: Text(t.profile.detailsForm.updateInterval),
subtitle: Text(
options?.updateInterval.toApproximateTime(
isRelativeToNow: false,
) ??
t.general.toggle.disabled,
),
leading: const Icon(Icons.update),
onTap: () async {
final intervalInHours = await SettingsInputDialog(
title: t.profile.detailsForm
.updateIntervalDialogTitle,
initialValue: options?.updateInterval.inHours,
optionalAction: (
t.general.state.disable,
() =>
notifier.setField(updateInterval: none()),
),
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (intervalInHours == null) return;
notifier.setField(
updateInterval: optionOf(intervalInHours),
);
},
),
),
ListTile(
title: Text(t.profile.detailsForm.updateInterval),
subtitle: Text(
state.profile.options?.updateInterval
.toApproximateTime(
isRelativeToNow: false,
) ??
t.general.toggle.disabled,
),
leading: const Icon(Icons.update),
onTap: () async {
final intervalInHours = await SettingsInputDialog(
title: t.profile.detailsForm
.updateIntervalDialogTitle,
initialValue:
state.profile.options?.updateInterval.inHours,
optionalAction: (
t.general.state.disable,
() => notifier.setField(updateInterval: none()),
),
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (intervalInHours == null) return;
notifier.setField(
updateInterval: optionOf(intervalInHours),
);
},
),
],
if (state.isEditing)
ListTile(
title: Text(t.profile.detailsForm.lastUpdate),

View File

@@ -49,21 +49,39 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
}).run();
}
Future<Unit> addProfile(String url) async {
Future<Unit> addProfile(String rawInput) async {
final activeProfile = await ref.read(activeProfileProvider.future);
final markAsActive =
activeProfile == null || ref.read(markNewProfileActiveProvider);
loggy.debug("adding profile, url: [$url]");
return ref
.read(profilesRepositoryProvider)
.addByUrl(url, markAsActive: markAsActive)
.getOrElse((l) {
loggy.warning("failed to add profile: $l");
throw l;
}).run();
if (LinkParser.parse(rawInput) case (final link)?) {
loggy.debug("adding profile, url: [${link.url}]");
return ref
.read(profilesRepositoryProvider)
.addByUrl(link.url, markAsActive: markAsActive)
.getOrElse((l) {
loggy.warning("failed to add profile: $l");
throw l;
}).run();
} else if (LinkParser.protocol(rawInput) case (final parsed)?) {
loggy.debug("adding profile, content");
return ref
.read(profilesRepositoryProvider)
.addByContent(
parsed.content,
name: parsed.name,
markAsActive: markAsActive,
)
.getOrElse((l) {
loggy.warning("failed to add profile: $l");
throw l;
}).run();
} else {
loggy.debug("invalid content");
throw const ProfileInvalidUrlFailure();
}
}
Future<Unit?> updateProfile(Profile profile) async {
Future<Unit?> updateProfile(RemoteProfile profile) async {
loggy.debug("updating profile");
return ref
.read(profilesRepositoryProvider)

View File

@@ -50,20 +50,22 @@ class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger {
await ref.read(profilesRepositoryProvider).watchAll().first;
if (failureOrProfiles case Right(value: final profiles)) {
for (final profile in profiles) {
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}]");
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}]");
}
}
}
}

View File

@@ -6,6 +6,7 @@ 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/utils/utils.dart';
@@ -29,8 +30,13 @@ class AddProfileModal extends HookConsumerWidget {
final addProfileMutation = useMutation(
initialOnFailure: (err) {
mutationTriggered.value = false;
// CustomToast.error(t.presentError(err)).show(context);
CustomAlertDialog.fromErr(t.presentError(err)).show(context);
if (err case ProfileInvalidUrlFailure()) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
} else {
CustomAlertDialog.fromErr(t.presentError(err)).show(context);
}
},
initialOnSuccess: () {
CustomToast.success(t.profile.save.successMsg).show(context);
@@ -102,24 +108,15 @@ class AddProfileModal extends HookConsumerWidget {
size: buttonWidth,
onTap: () async {
final captureResult =
await Clipboard.getData(Clipboard.kTextPlain);
final link =
LinkParser.parse(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 {
if (context.mounted) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
}
}
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),
);
},
),
const Gap(buttonsGap),
@@ -134,24 +131,15 @@ class AddProfileModal extends HookConsumerWidget {
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 {
if (context.mounted) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
}
if (addProfileMutation.state.isInProgress) {
return;
}
mutationTriggered.value = true;
addProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.addProfile(captureResult),
);
},
)
else