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

View File

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

View File

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

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 @override
TaskEither<ProfileFailure, Unit> setAsActive(String id) { TaskEither<ProfileFailure, Unit> setAsActive(String id) {
return TaskEither.tryCatch( return TaskEither.tryCatch(

View File

@@ -23,6 +23,8 @@ abstract class ProfilesRepository {
TaskEither<ProfileFailure, Unit> update(Profile baseProfile); TaskEither<ProfileFailure, Unit> update(Profile baseProfile);
TaskEither<ProfileFailure, Unit> edit(Profile profile);
TaskEither<ProfileFailure, Unit> setAsActive(String id); TaskEither<ProfileFailure, Unit> setAsActive(String id);
TaskEither<ProfileFailure, Unit> delete(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'); loggy.warning('profile with id: [$id] does not exist');
throw const ProfileNotFoundFailure(); throw const ProfileNotFoundFailure();
} }
_originalProfile = profile;
return ProfileDetailState(profile: profile, isEditing: true); return ProfileDetailState(profile: profile, isEditing: true);
}, },
); );
} }
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider); 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)) { if (state case AsyncData(:final value)) {
state = AsyncData( state = AsyncData(
value.copyWith( value.copyWith(
profile: value.profile.copyWith( profile: value.profile.copyWith(
name: name ?? value.profile.name, name: name ?? value.profile.name,
url: url ?? value.profile.url, 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( loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]', 'saving profile, url: [${profile.url}], name: [${profile.name}]',
); );
state = AsyncData(value.copyWith(save: const MutationInProgress())) state = AsyncData(value.copyWith(save: const MutationInProgress()));
.copyWithPrevious(state);
Either<ProfileFailure, Unit>? failureOrSuccess; Either<ProfileFailure, Unit>? failureOrSuccess;
if (profile.name.isBlank || profile.url.isBlank) { if (profile.name.isBlank || profile.url.isBlank) {
loggy.debug('profile save: invalid arguments'); loggy.debug('profile save: invalid arguments');
} else if (value.isEditing) { } else if (value.isEditing) {
loggy.debug('updating profile'); if (_originalProfile?.url == profile.url) {
failureOrSuccess = await _profilesRepo.update(profile).run(); loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.edit(profile).run();
} else {
loggy.debug('updating profile');
failureOrSuccess = await _profilesRepo.update(profile).run();
}
} else { } else {
loggy.debug('adding profile, url: [${profile.url}]'); loggy.debug('adding profile, url: [${profile.url}]');
failureOrSuccess = await _profilesRepo.add(profile).run(); failureOrSuccess = await _profilesRepo.add(profile).run();
@@ -87,7 +99,32 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
value.save, value.save,
showErrorMessages: true, 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; if (value.delete.isInProgress) return;
final profile = value.profile; final profile = value.profile;
loggy.debug('deleting profile'); loggy.debug('deleting profile');
state = AsyncData( state = AsyncData(value.copyWith(delete: const MutationInProgress()));
value.copyWith(delete: const MutationState.inProgress()),
).copyWithPrevious(state);
final result = await _profilesRepo.delete(profile.id).run(); final result = await _profilesRepo.delete(profile.id).run();
state = AsyncData( state = AsyncData(
value.copyWith( value.copyWith(
@@ -107,7 +142,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
(_) => const MutationSuccess(), (_) => const MutationSuccess(),
), ),
), ),
).copyWithPrevious(state); );
} }
} }
} }

View File

@@ -13,10 +13,10 @@ class ProfileDetailState with _$ProfileDetailState {
@Default(false) bool isEditing, @Default(false) bool isEditing,
@Default(false) bool showErrorMessages, @Default(false) bool showErrorMessages,
@Default(MutationState.initial()) MutationState<ProfileFailure> save, @Default(MutationState.initial()) MutationState<ProfileFailure> save,
@Default(MutationState.initial()) MutationState<ProfileFailure> update,
@Default(MutationState.initial()) MutationState<ProfileFailure> delete, @Default(MutationState.initial()) MutationState<ProfileFailure> delete,
}) = _ProfileDetailState; }) = _ProfileDetailState;
bool get isBusy => bool get isBusy =>
(save.isInProgress || save is MutationSuccess) || save.isInProgress || delete.isInProgress || update.isInProgress;
(delete.isInProgress || delete is MutationSuccess);
} }

View File

@@ -1,36 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:fpdart/fpdart.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/domain/failures.dart'; import 'package:hiddify/domain/failures.dart';
import 'package:hiddify/features/common/confirmation_dialogs.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart';
import 'package:hiddify/features/profile_detail/notifier/notifier.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:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.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 { class ProfileDetailPage extends HookConsumerWidget with PresLogger {
const ProfileDetailPage( const ProfileDetailPage(this.id, {super.key});
this.id, {
super.key,
this.url,
this.name,
});
final String id; final String id;
final String? url;
final String? name;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final provider =
profileDetailNotifierProvider(id, url: url, profileName: name);
final t = ref.watch(translationsProvider); 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( ref.listen(
provider.select((data) => data.whenData((value) => value.save)), 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)) { if (asyncSave case AsyncData(value: final save)) {
switch (save) { switch (save) {
case MutationFailure(:final failure): case MutationFailure(:final failure):
CustomToast.error(t.printError(failure)).show(context); CustomAlertDialog.fromErr(t.presentError(failure)).show(context);
case MutationSuccess(): case MutationSuccess():
CustomToast.success(t.profile.save.successMsg).show(context); CustomToast.success(t.profile.save.successMsg).show(context);
WidgetsBinding.instance.addPostFrameCallback( 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( ref.listen(
provider.select((data) => data.whenData((value) => value.delete)), provider.select((data) => data.whenData((value) => value.delete)),
(_, asyncSave) { (_, asyncDelete) {
if (asyncSave case AsyncData(value: final delete)) { if (asyncDelete case AsyncData(value: final delete)) {
switch (delete) { switch (delete) {
case MutationFailure(:final failure): case MutationFailure(:final failure):
CustomToast.error(t.printError(failure)).show(context); CustomToast.error(t.printError(failure)).show(context);
@@ -70,52 +74,125 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
}, },
); );
switch (asyncState) { switch (ref.watch(provider)) {
case AsyncData(value: final state): case AsyncData(value: final state):
final showLoadingOverlay = state.isBusy ||
state.save is MutationSuccess ||
state.delete is MutationSuccess;
return Stack( return Stack(
children: [ children: [
Scaffold( Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
pinned: true,
title: Text(t.profile.detailsPageTitle), title: Text(t.profile.detailsPageTitle),
), pinned: true,
const SliverGap(8), actions: [
SliverPadding( if (state.isEditing)
padding: const EdgeInsets.symmetric(horizontal: 16), PopupMenuButton(
sliver: Form( itemBuilder: (context) {
autovalidateMode: state.showErrorMessages return [
? AutovalidateMode.always PopupMenuItem(
: AutovalidateMode.disabled, child: Text(t.profile.update.buttonTxt),
child: SliverList( onTap: () async {
delegate: SliverChildListDelegate( await notifier.updateProfile();
[ },
const Gap(8), ),
CustomTextFormField( PopupMenuItem(
initialValue: state.profile.name, child: Text(t.profile.delete.buttonTxt),
onChanged: (value) => onTap: () async {
notifier.setField(name: value), final deleteConfirmed =
validator: (value) => (value?.isEmpty ?? true) await showConfirmationDialog(
? t.profile.detailsForm.emptyNameMsg context,
: null, title: t.profile.delete.buttonTxt,
label: t.profile.detailsForm.nameHint, message: t.profile.delete.confirmationMsg,
), );
const Gap(16), if (deleteConfirmed) {
CustomTextFormField( await notifier.delete();
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.urlHint.toUpperCase(),
),
],
), ),
), ],
),
Form(
autovalidateMode: state.showErrorMessages
? AutovalidateMode.always
: AutovalidateMode.disabled,
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.nameLabel,
hint: t.profile.detailsForm.nameHint,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
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(
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( SliverFillRemaining(
@@ -133,33 +210,14 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
spacing: 12, spacing: 12,
overflowAlignment: OverflowBarAlignment.end, overflowAlignment: OverflowBarAlignment.end,
children: [ 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( OutlinedButton(
onPressed: context.pop,
child: Text(
MaterialLocalizations.of(context)
.cancelButtonLabel,
),
),
FilledButton(
onPressed: notifier.save, onPressed: notifier.save,
child: Text(t.profile.save.buttonText), child: Text(t.profile.save.buttonText),
), ),
@@ -172,7 +230,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
], ],
), ),
), ),
if (state.isBusy) if (showLoadingOverlay)
Positioned.fill( Positioned.fill(
child: Container( child: Container(
color: Colors.black54, 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: default:
return const Scaffold(); return const Scaffold();
} }

View File

@@ -14,6 +14,7 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
this.mapTo, this.mapTo,
this.validator, this.validator,
this.resetValue, this.resetValue,
this.optionalAction,
this.icon, this.icon,
this.digitsOnly = false, this.digitsOnly = false,
}); });
@@ -23,6 +24,7 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
final T? Function(String value)? mapTo; final T? Function(String value)? mapTo;
final bool Function(String value)? validator; final bool Function(String value)? validator;
final T? resetValue; final T? resetValue;
final (String text, VoidCallback)? optionalAction;
final IconData? icon; final IconData? icon;
final bool digitsOnly; final bool digitsOnly;
@@ -55,6 +57,15 @@ class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
autovalidateMode: AutovalidateMode.always, autovalidateMode: AutovalidateMode.always,
), ),
actions: [ 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) if (resetValue != null)
TextButton( TextButton(
onPressed: () async { onPressed: () async {

View File

@@ -13,7 +13,7 @@ class CustomTextFormField extends HookConsumerWidget {
this.suffixIcon, this.suffixIcon,
this.label, this.label,
this.hint, this.hint,
this.maxLines = 1, this.maxLines,
this.isDense = false, this.isDense = false,
this.autoValidate = false, this.autoValidate = false,
this.autoCorrect = false, this.autoCorrect = false,
@@ -26,7 +26,7 @@ class CustomTextFormField extends HookConsumerWidget {
final Widget? suffixIcon; final Widget? suffixIcon;
final String? label; final String? label;
final String? hint; final String? hint;
final int maxLines; final int? maxLines;
final bool isDense; final bool isDense;
final bool autoValidate; final bool autoValidate;
final bool autoCorrect; 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_log_printer.dart';
export 'custom_loggers.dart'; export 'custom_loggers.dart';
export 'custom_text_form_field.dart'; export 'custom_text_form_field.dart';
export 'date_time_formatter.dart';
export 'link_parsers.dart'; export 'link_parsers.dart';
export 'mutation_state.dart'; export 'mutation_state.dart';
export 'number_formatters.dart'; export 'number_formatters.dart';