Refactor profile details page
This commit is contained in:
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
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<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);
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
7
lib/utils/date_time_formatter.dart
Normal file
7
lib/utils/date_time_formatter.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
extension DateTimeFormatter on DateTime {
|
||||
String format() {
|
||||
return DateFormat.yMMMd().add_Hm().format(this);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user