From f126bf32017ff964260c7673f7b07f7cc1f020e2 Mon Sep 17 00:00:00 2001 From: problematicconsumer Date: Thu, 28 Sep 2023 14:03:45 +0330 Subject: [PATCH] Refactor profile details page --- assets/translations/strings.i18n.json | 14 +- assets/translations/strings_fa.i18n.json | 14 +- lib/core/router/routes/shared_routes.dart | 12 +- .../repository/profiles_repository_impl.dart | 17 ++ lib/domain/profiles/profiles_repository.dart | 2 + .../notifier/profile_detail_notifier.dart | 57 ++++- .../notifier/profile_detail_state.dart | 4 +- .../view/profile_detail_page.dart | 238 +++++++++++------- .../widgets/settings_input_dialog.dart | 11 + lib/utils/custom_text_form_field.dart | 4 +- lib/utils/date_time_formatter.dart | 7 + lib/utils/utils.dart | 1 + 12 files changed, 267 insertions(+), 114 deletions(-) create mode 100644 lib/utils/date_time_formatter.dart diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index 417e7810..2456c252 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -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": { diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 30b46f35..77ddea39 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -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": { diff --git a/lib/core/router/routes/shared_routes.dart b/lib/core/router/routes/shared_routes.dart index 12a13b59..8fdadd06 100644 --- a/lib/core/router/routes/shared_routes.dart +++ b/lib/core/router/routes/shared_routes.dart @@ -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 $parentNavigatorKey = rootNavigatorKey; @override Page buildPage(BuildContext context, GoRouterState state) { - return MaterialPage( + return const MaterialPage( fullscreenDialog: true, name: name, - child: ProfileDetailPage( - "new", - url: url, - name: profileName, - ), + child: ProfileDetailPage("new"), ); } } diff --git a/lib/data/repository/profiles_repository_impl.dart b/lib/data/repository/profiles_repository_impl.dart index ac9169cc..bd2ae9d9 100644 --- a/lib/data/repository/profiles_repository_impl.dart +++ b/lib/data/repository/profiles_repository_impl.dart @@ -159,6 +159,23 @@ class ProfilesRepositoryImpl ); } + @override + TaskEither 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 setAsActive(String id) { return TaskEither.tryCatch( diff --git a/lib/domain/profiles/profiles_repository.dart b/lib/domain/profiles/profiles_repository.dart index 2c719862..803417cb 100644 --- a/lib/domain/profiles/profiles_repository.dart +++ b/lib/domain/profiles/profiles_repository.dart @@ -23,6 +23,8 @@ abstract class ProfilesRepository { TaskEither update(Profile baseProfile); + TaskEither edit(Profile profile); + TaskEither setAsActive(String id); TaskEither delete(String id); diff --git a/lib/features/profile_detail/notifier/profile_detail_notifier.dart b/lib/features/profile_detail/notifier/profile_detail_notifier.dart index 1d27570a..27a803a7 100644 --- a/lib/features/profile_detail/notifier/profile_detail_notifier.dart +++ b/lib/features/profile_detail/notifier/profile_detail_notifier.dart @@ -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? 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? failureOrSuccess; if (profile.name.isBlank || profile.url.isBlank) { loggy.debug('profile save: invalid arguments'); } else if (value.isEditing) { - loggy.debug('updating profile'); - failureOrSuccess = await _profilesRepo.update(profile).run(); + 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 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); + ); } } } diff --git a/lib/features/profile_detail/notifier/profile_detail_state.dart b/lib/features/profile_detail/notifier/profile_detail_state.dart index d4b96ac3..344cd7c5 100644 --- a/lib/features/profile_detail/notifier/profile_detail_state.dart +++ b/lib/features/profile_detail/notifier/profile_detail_state.dart @@ -13,10 +13,10 @@ class ProfileDetailState with _$ProfileDetailState { @Default(false) bool isEditing, @Default(false) bool showErrorMessages, @Default(MutationState.initial()) MutationState save, + @Default(MutationState.initial()) MutationState update, @Default(MutationState.initial()) MutationState delete, }) = _ProfileDetailState; bool get isBusy => - (save.isInProgress || save is MutationSuccess) || - (delete.isInProgress || delete is MutationSuccess); + save.isInProgress || delete.isInProgress || update.isInProgress; } diff --git a/lib/features/profile_detail/view/profile_detail_page.dart b/lib/features/profile_detail/view/profile_detail_page.dart index 0a08e256..66bf4f94 100644 --- a/lib/features/profile_detail/view/profile_detail_page.dart +++ b/lib/features/profile_detail/view/profile_detail_page.dart @@ -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,52 +74,125 @@ 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), - ), - const SliverGap(8), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: Form( - autovalidateMode: state.showErrorMessages - ? AutovalidateMode.always - : AutovalidateMode.disabled, - child: SliverList( - delegate: SliverChildListDelegate( - [ - const Gap(8), - 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, - ), - const Gap(16), - 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.urlHint.toUpperCase(), - ), - ], + pinned: true, + actions: [ + if (state.isEditing) + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Text(t.profile.update.buttonTxt), + onTap: () async { + await notifier.updateProfile(); + }, + ), + 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.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( @@ -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(); } diff --git a/lib/features/settings/widgets/settings_input_dialog.dart b/lib/features/settings/widgets/settings_input_dialog.dart index 80140036..394158da 100644 --- a/lib/features/settings/widgets/settings_input_dialog.dart +++ b/lib/features/settings/widgets/settings_input_dialog.dart @@ -14,6 +14,7 @@ class SettingsInputDialog extends HookConsumerWidget with PresLogger { this.mapTo, this.validator, this.resetValue, + this.optionalAction, this.icon, this.digitsOnly = false, }); @@ -23,6 +24,7 @@ class SettingsInputDialog 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 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 { diff --git a/lib/utils/custom_text_form_field.dart b/lib/utils/custom_text_form_field.dart index 01740b53..32c98060 100644 --- a/lib/utils/custom_text_form_field.dart +++ b/lib/utils/custom_text_form_field.dart @@ -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; diff --git a/lib/utils/date_time_formatter.dart b/lib/utils/date_time_formatter.dart new file mode 100644 index 00000000..b7bd517e --- /dev/null +++ b/lib/utils/date_time_formatter.dart @@ -0,0 +1,7 @@ +import 'package:intl/intl.dart'; + +extension DateTimeFormatter on DateTime { + String format() { + return DateFormat.yMMMd().add_Hm().format(this); + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 76320bd1..d7a91f3c 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -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';