Refactor profile details page

This commit is contained in:
problematicconsumer
2023-09-28 14:03:45 +03:30
parent fa32b85628
commit f126bf3201
12 changed files with 267 additions and 114 deletions

View File

@@ -6,6 +6,9 @@
"enabled": "Enabled",
"disabled": "Disabled"
},
"state": {
"disable": "Disable"
},
"sort": "Sort",
"sortBy": "Sort by"
},
@@ -78,10 +81,15 @@
"successMsg": "Profile saved successfully"
},
"detailsForm": {
"nameHint": "Name",
"urlHint": "URL",
"nameLabel": "Name",
"nameHint": "Profile name",
"urlLabel": "URL",
"urlHint": "Full config URL",
"emptyNameMsg": "Name is required",
"invalidUrlMsg": "Invalid URL"
"invalidUrlMsg": "Invalid URL",
"lastUpdate": "Last Update",
"updateInterval": "Auto Update",
"updateIntervalDialogTitle": "Auto Update Interval (in hours)"
}
},
"proxies": {

View File

@@ -6,6 +6,9 @@
"enabled": "فعال",
"disabled": "غیر فعال"
},
"state": {
"disable": "غیر فعال"
},
"sort": "مرتب‌سازی",
"sortBy": "مرتب‌سازی براساس"
},
@@ -78,10 +81,15 @@
"successMsg": "پروفایل با موفقیت ذخیره شد"
},
"detailsForm": {
"nameHint": "نام",
"urlHint": ینک",
"nameLabel": "نام",
"nameHint": "نام پروفایل",
"urlLabel": "لینک",
"urlHint": "آدرس کامل کانفیگ",
"emptyNameMsg": "نام نمی‌تواند خالی باشد",
"invalidUrlMsg": "لینک نامعتبر"
"invalidUrlMsg": "لینک نامعتبر",
"lastUpdate": "آخرین بروزرسانی",
"updateInterval": "بروزرسانی خودکار",
"updateIntervalDialogTitle": "فاصله زمانی بروزرسانی خودکار (ساعت)"
}
},
"proxies": {

View File

@@ -93,24 +93,18 @@ class ProfilesRoute extends GoRouteData {
}
class NewProfileRoute extends GoRouteData {
const NewProfileRoute({this.url, this.profileName});
const NewProfileRoute();
static const path = 'profiles/new';
static const name = 'New Profile';
final String? url;
final String? profileName;
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey;
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return MaterialPage(
return const MaterialPage(
fullscreenDialog: true,
name: name,
child: ProfileDetailPage(
"new",
url: url,
name: profileName,
),
child: ProfileDetailPage("new"),
);
}
}

View File

@@ -159,6 +159,23 @@ class ProfilesRepositoryImpl
);
}
@override
TaskEither<ProfileFailure, Unit> edit(Profile profile) {
return exceptionHandler(
() async {
loggy.debug(
"editing profile [${profile.name} (${profile.id})]",
);
await profilesDao.edit(profile);
return right(unit);
},
(error, stackTrace) {
loggy.warning("error editing profile", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> setAsActive(String id) {
return TaskEither.tryCatch(

View File

@@ -23,6 +23,8 @@ abstract class ProfilesRepository {
TaskEither<ProfileFailure, Unit> update(Profile baseProfile);
TaskEither<ProfileFailure, Unit> edit(Profile profile);
TaskEither<ProfileFailure, Unit> setAsActive(String id);
TaskEither<ProfileFailure, Unit> delete(String id);

View File

@@ -39,23 +39,31 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
loggy.warning('profile with id: [$id] does not exist');
throw const ProfileNotFoundFailure();
}
_originalProfile = profile;
return ProfileDetailState(profile: profile, isEditing: true);
},
);
}
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider);
Profile? _originalProfile;
void setField({String? name, String? url}) {
void setField({String? name, String? url, Option<int>? updateInterval}) {
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)),
),
),
).copyWithPrevious(state);
),
);
}
}
@@ -66,14 +74,18 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]',
);
state = AsyncData(value.copyWith(save: const MutationInProgress()))
.copyWithPrevious(state);
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) {
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();
@@ -87,7 +99,32 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
value.save,
showErrorMessages: true,
),
).copyWithPrevious(state);
);
}
}
Future<void> updateProfile() async {
if (state case AsyncData(:final value)) {
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)
.flatMap((_) => _profilesRepo.get(id))
.run();
state = AsyncData(
value.copyWith(
update: failureOrUpdatedProfile.match(
(l) => MutationFailure(l),
(_) => const MutationSuccess(),
),
profile: failureOrUpdatedProfile.match(
(_) => profile,
(updatedProfile) => updatedProfile ?? profile,
),
),
);
}
}
@@ -96,9 +133,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
if (value.delete.isInProgress) return;
final profile = value.profile;
loggy.debug('deleting profile');
state = AsyncData(
value.copyWith(delete: const MutationState.inProgress()),
).copyWithPrevious(state);
state = AsyncData(value.copyWith(delete: const MutationInProgress()));
final result = await _profilesRepo.delete(profile.id).run();
state = AsyncData(
value.copyWith(
@@ -107,7 +142,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
(_) => const MutationSuccess(),
),
),
).copyWithPrevious(state);
);
}
}
}

View File

@@ -13,10 +13,10 @@ class ProfileDetailState with _$ProfileDetailState {
@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 || save is MutationSuccess) ||
(delete.isInProgress || delete is MutationSuccess);
save.isInProgress || delete.isInProgress || update.isInProgress;
}

View File

@@ -1,36 +1,26 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
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/features/common/confirmation_dialogs.dart';
import 'package:hiddify/features/profile_detail/notifier/notifier.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';
// TODO: test and improve
// TODO: prevent popping screen when busy
class ProfileDetailPage extends HookConsumerWidget with PresLogger {
const ProfileDetailPage(
this.id, {
super.key,
this.url,
this.name,
});
const ProfileDetailPage(this.id, {super.key});
final String id;
final String? url;
final String? name;
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider =
profileDetailNotifierProvider(id, url: url, profileName: name);
final t = ref.watch(translationsProvider);
final asyncState = ref.watch(provider);
final notifier = ref.watch(provider.notifier);
final themeData = Theme.of(context);
final provider = profileDetailNotifierProvider(id);
final notifier = ref.watch(provider.notifier);
ref.listen(
provider.select((data) => data.whenData((value) => value.save)),
@@ -38,7 +28,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
if (asyncSave case AsyncData(value: final save)) {
switch (save) {
case MutationFailure(:final failure):
CustomToast.error(t.printError(failure)).show(context);
CustomAlertDialog.fromErr(t.presentError(failure)).show(context);
case MutationSuccess():
CustomToast.success(t.profile.save.successMsg).show(context);
WidgetsBinding.instance.addPostFrameCallback(
@@ -51,10 +41,24 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
},
);
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);
}
}
},
);
ref.listen(
provider.select((data) => data.whenData((value) => value.delete)),
(_, asyncSave) {
if (asyncSave case AsyncData(value: final delete)) {
(_, asyncDelete) {
if (asyncDelete case AsyncData(value: final delete)) {
switch (delete) {
case MutationFailure(:final failure):
CustomToast.error(t.printError(failure)).show(context);
@@ -70,54 +74,127 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
},
);
switch (asyncState) {
switch (ref.watch(provider)) {
case AsyncData(value: final state):
final showLoadingOverlay = state.isBusy ||
state.save is MutationSuccess ||
state.delete is MutationSuccess;
return Stack(
children: [
Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(t.profile.detailsPageTitle),
pinned: true,
actions: [
if (state.isEditing)
PopupMenuButton(
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(t.profile.update.buttonTxt),
onTap: () async {
await notifier.updateProfile();
},
),
const SliverGap(8),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: Form(
PopupMenuItem(
child: Text(t.profile.delete.buttonTxt),
onTap: () async {
final deleteConfirmed =
await showConfirmationDialog(
context,
title: t.profile.delete.buttonTxt,
message: t.profile.delete.confirmationMsg,
);
if (deleteConfirmed) {
await notifier.delete();
}
},
),
];
},
),
],
),
Form(
autovalidateMode: state.showErrorMessages
? AutovalidateMode.always
: AutovalidateMode.disabled,
child: SliverList(
delegate: SliverChildListDelegate(
[
const Gap(8),
CustomTextFormField(
child: SliverList.list(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: CustomTextFormField(
initialValue: state.profile.name,
onChanged: (value) =>
notifier.setField(name: value),
validator: (value) => (value?.isEmpty ?? true)
? t.profile.detailsForm.emptyNameMsg
: null,
label: t.profile.detailsForm.nameHint,
label: t.profile.detailsForm.nameLabel,
hint: t.profile.detailsForm.nameHint,
),
const Gap(16),
CustomTextFormField(
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: CustomTextFormField(
initialValue: state.profile.url,
onChanged: (value) =>
notifier.setField(url: value),
onChanged: (value) => notifier.setField(url: value),
validator: (value) =>
(value != null && !isUrl(value))
? t.profile.detailsForm.invalidUrlMsg
: null,
label:
t.profile.detailsForm.urlHint.toUpperCase(),
label: t.profile.detailsForm.urlLabel,
hint: t.profile.detailsForm.urlHint,
),
),
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),
subtitle: Text(state.profile.lastUpdate.format()),
dense: true,
),
],
),
),
),
),
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
@@ -133,33 +210,14 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
spacing: 12,
overflowAlignment: OverflowBarAlignment.end,
children: [
if (state.isEditing)
FilledButton(
onPressed: () async {
final deleteConfirmed =
await showConfirmationDialog(
context,
title: t.profile.delete.buttonTxt,
message: t.profile.delete.confirmationMsg,
);
if (deleteConfirmed) {
await notifier.delete();
}
},
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(
themeData.colorScheme.errorContainer,
),
),
child: Text(
t.profile.delete.buttonTxt,
style: TextStyle(
color: themeData
.colorScheme.onErrorContainer,
),
),
),
OutlinedButton(
onPressed: context.pop,
child: Text(
MaterialLocalizations.of(context)
.cancelButtonLabel,
),
),
FilledButton(
onPressed: notifier.save,
child: Text(t.profile.save.buttonText),
),
@@ -172,7 +230,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
],
),
),
if (state.isBusy)
if (showLoadingOverlay)
Positioned.fill(
child: Container(
color: Colors.black54,
@@ -190,7 +248,19 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
],
);
// TODO: handle loading and error states
case AsyncError(:final error):
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
title: Text(t.profile.detailsPageTitle),
pinned: true,
),
SliverErrorBodyPlaceholder(t.printError(error)),
],
),
);
default:
return const Scaffold();
}

View File

@@ -14,6 +14,7 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
this.mapTo,
this.validator,
this.resetValue,
this.optionalAction,
this.icon,
this.digitsOnly = false,
});
@@ -23,6 +24,7 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
final T? Function(String value)? mapTo;
final bool Function(String value)? validator;
final T? resetValue;
final (String text, VoidCallback)? optionalAction;
final IconData? icon;
final bool digitsOnly;
@@ -55,6 +57,15 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
autovalidateMode: AutovalidateMode.always,
),
actions: [
if (optionalAction != null)
TextButton(
onPressed: () async {
optionalAction!.$2();
await Navigator.of(context)
.maybePop(T == String ? textController.value.text : null);
},
child: Text(optionalAction!.$1.toUpperCase()),
),
if (resetValue != null)
TextButton(
onPressed: () async {

View File

@@ -13,7 +13,7 @@ class CustomTextFormField extends HookConsumerWidget {
this.suffixIcon,
this.label,
this.hint,
this.maxLines = 1,
this.maxLines,
this.isDense = false,
this.autoValidate = false,
this.autoCorrect = false,
@@ -26,7 +26,7 @@ class CustomTextFormField extends HookConsumerWidget {
final Widget? suffixIcon;
final String? label;
final String? hint;
final int maxLines;
final int? maxLines;
final bool isDense;
final bool autoValidate;
final bool autoCorrect;

View File

@@ -0,0 +1,7 @@
import 'package:intl/intl.dart';
extension DateTimeFormatter on DateTime {
String format() {
return DateFormat.yMMMd().add_Hm().format(this);
}
}

View File

@@ -5,6 +5,7 @@ export 'callback_debouncer.dart';
export 'custom_log_printer.dart';
export 'custom_loggers.dart';
export 'custom_text_form_field.dart';
export 'date_time_formatter.dart';
export 'link_parsers.dart';
export 'mutation_state.dart';
export 'number_formatters.dart';