refactor(legacy-account): migrate change_password to AsyncNotifier

This commit is contained in:
2026-04-21 20:35:18 +02:00
parent 72c88cc4b0
commit 7746d08759
19 changed files with 637 additions and 589 deletions

View File

@@ -0,0 +1,34 @@
import 'package:sf_localizations/sf_localizations.dart';
class PasswordValidator {
const PasswordValidator._();
static final _upperRegex = RegExp(r'[A-Z]');
static final _digitRegex = RegExp(r'[0-9]');
static final _specialRegex = RegExp(r'[!@#$%^&*(),.?":{}|<>\-_+=\[\]\\\/~`]');
static const int minLength = 8;
static String? validate({
required String password,
required String repeat,
}) {
final trimmed = password.trim();
final trimmedRepeat = repeat.trim();
if (trimmed.isEmpty) return I18n.errorPasswordRequired;
if (trimmedRepeat.isEmpty) return I18n.errorPasswordRequired;
if (trimmed != trimmedRepeat) return I18n.errorMessageUnequalPasswords;
if (trimmed.length < minLength) return I18n.errorMessagePasswordTooShort;
if (!_upperRegex.hasMatch(trimmed)) {
return I18n.errorMessagePasswordNoCapitals;
}
if (!_digitRegex.hasMatch(trimmed)) {
return I18n.errorMessagePasswordNoNumbers;
}
if (!_specialRegex.hasMatch(trimmed)) {
return I18n.errorMessagePasswordNoSpecialChars;
}
return null;
}
}

View File

@@ -1,23 +1,73 @@
import 'package:account/src/features/change_password/presentation/state/change_password_view_model.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:account/src/features/change_password/domain/password_validator.dart';
import 'package:account/src/features/change_password/presentation/providers/change_password_controller.dart';
import 'package:account/src/features/change_password/presentation/providers/change_password_visibility_provider.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'package:navigation/navigation.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
class ChangePasswordScreen extends ConsumerWidget {
class ChangePasswordScreen extends ConsumerStatefulWidget {
final NavigationContract navigationContract;
const ChangePasswordScreen({super.key, required this.navigationContract});
@override
Widget build(BuildContext context, WidgetRef ref) {
final password = ref.watch(
changePasswordViewModelProvider.select((s) => s.newPassword),
ConsumerState<ChangePasswordScreen> createState() =>
_ChangePasswordScreenState();
}
class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
late final TextEditingController _newPasswordController;
late final TextEditingController _repeatPasswordController;
String? _localError;
@override
void initState() {
super.initState();
_newPasswordController = TextEditingController();
_repeatPasswordController = TextEditingController();
}
@override
void dispose() {
_newPasswordController.dispose();
_repeatPasswordController.dispose();
super.dispose();
}
void _onSubmit() {
final error = PasswordValidator.validate(
password: _newPasswordController.text,
repeat: _repeatPasswordController.text,
);
if (error != null) {
setState(() => _localError = error);
return;
}
setState(() => _localError = null);
ref
.read(changePasswordControllerProvider.notifier)
.submit(password: _newPasswordController.text.trim());
}
@override
Widget build(BuildContext context) {
ref.listen(changePasswordControllerProvider, (prev, next) {
next.showErrorOn(context);
if (prev != null &&
prev.isLoading &&
!next.isLoading &&
!next.hasError) {
widget.navigationContract.goBack();
}
});
final submitState = ref.watch(changePasswordControllerProvider);
return LegacyPageLayout(
title: context.translate(I18n.changePassword),
@@ -30,65 +80,90 @@ class ChangePasswordScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const _NewPasswordSection(),
_NewPasswordField(controller: _newPasswordController),
SizedBox(height: SizeUtils.getByScreen(small: 24, big: 22)),
const _RepeatPasswordSection(),
_RepeatPasswordField(controller: _repeatPasswordController),
SizedBox(height: SizeUtils.getByScreen(small: 24, big: 22)),
_PasswordCriteriaList(password: password),
const _ErrorMessageSection(),
_PasswordCriteriaList(
newController: _newPasswordController,
repeatController: _repeatPasswordController,
),
if (_localError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
context.translate(_localError!),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
),
),
),
footer: _SaveSection(navigationContract: navigationContract),
footer: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
child: PrimaryButton(
onPressed: submitState.isLoading ? null : _onSubmit,
text: context.translate(I18n.save),
color: context.sfColors.legacyPrimary,
),
),
);
}
}
class _NewPasswordSection extends ConsumerWidget {
const _NewPasswordSection();
class _NewPasswordField extends ConsumerWidget {
final TextEditingController controller;
const _NewPasswordField({required this.controller});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(changePasswordViewModelProvider.notifier);
final showPassword = ref.watch(
changePasswordViewModelProvider.select((s) => s.showNewPassword),
);
final visibility = ref.watch(changePasswordVisibilityProvider);
return CustomTextField(
controller: vm.newPasswordController,
controller: controller,
hint: '********',
label: context.translate(I18n.newPassword),
showPassword: showPassword,
onVisibilityChanged: vm.toggleNewPasswordVisibility,
showPassword: visibility.showNew,
onVisibilityChanged: ref
.read(changePasswordVisibilityProvider.notifier)
.toggleNew,
);
}
}
class _RepeatPasswordSection extends ConsumerWidget {
const _RepeatPasswordSection();
class _RepeatPasswordField extends ConsumerWidget {
final TextEditingController controller;
const _RepeatPasswordField({required this.controller});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(changePasswordViewModelProvider.notifier);
final showPassword = ref.watch(
changePasswordViewModelProvider.select((s) => s.showRepeatedPassword),
);
final visibility = ref.watch(changePasswordVisibilityProvider);
return CustomTextField(
controller: vm.repeatPasswordController,
controller: controller,
hint: '********',
label: context.translate(I18n.repeatPassword),
showPassword: showPassword,
onVisibilityChanged: vm.toggleRepeatedPasswordVisibility,
showPassword: visibility.showRepeated,
onVisibilityChanged: ref
.read(changePasswordVisibilityProvider.notifier)
.toggleRepeated,
);
}
}
class _PasswordCriteriaList extends StatelessWidget {
final String password;
final TextEditingController newController;
final TextEditingController repeatController;
const _PasswordCriteriaList({required this.password});
const _PasswordCriteriaList({
required this.newController,
required this.repeatController,
});
static final _upperRegex = RegExp(r'[A-Z]');
static final _digitRegex = RegExp(r'[0-9]');
@@ -96,33 +171,45 @@ class _PasswordCriteriaList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasInput = password.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
_CriteriaRow(
label: context.translate(I18n.passwordLength),
met: password.length >= 8,
hasInput: hasInput,
),
_CriteriaRow(
label: context.translate(I18n.passwordCapital),
met: _upperRegex.hasMatch(password),
hasInput: hasInput,
),
_CriteriaRow(
label: context.translate(I18n.passwordNumber),
met: _digitRegex.hasMatch(password),
hasInput: hasInput,
),
_CriteriaRow(
label: context.translate(I18n.passwordSpecial),
met: _specialRegex.hasMatch(password),
hasInput: hasInput,
),
],
return ListenableBuilder(
listenable: Listenable.merge([newController, repeatController]),
builder: (_, __) {
final password = newController.text;
final repeat = repeatController.text;
final hasInput = password.isNotEmpty;
final hasRepeatInput = repeat.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
_CriteriaRow(
label: context.translate(I18n.passwordLength),
met: password.length >= PasswordValidator.minLength,
hasInput: hasInput,
),
_CriteriaRow(
label: context.translate(I18n.passwordCapital),
met: _upperRegex.hasMatch(password),
hasInput: hasInput,
),
_CriteriaRow(
label: context.translate(I18n.passwordNumber),
met: _digitRegex.hasMatch(password),
hasInput: hasInput,
),
_CriteriaRow(
label: context.translate(I18n.passwordSpecial),
met: _specialRegex.hasMatch(password),
hasInput: hasInput,
),
_CriteriaRow(
label: context.translate(I18n.passwordMatch),
met: password.isNotEmpty && password == repeat,
hasInput: hasInput && hasRepeatInput,
),
],
);
},
);
}
}
@@ -163,75 +250,3 @@ class _CriteriaRow extends StatelessWidget {
);
}
}
class _ErrorMessageSection extends ConsumerWidget {
const _ErrorMessageSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorMessage = ref.watch(
changePasswordViewModelProvider.select((s) => s.errorMessage),
);
if (errorMessage.isNotEmpty) {
return Column(
children: [
const SizedBox(height: 8),
Text(
errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
);
} else {
return SizedBox.shrink();
}
}
}
class _SaveSection extends ConsumerWidget {
final NavigationContract navigationContract;
const _SaveSection({required this.navigationContract});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(changePasswordViewModelProvider.notifier);
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 10),
child: PrimaryButton(
onPressed: () async {
await vm.submit();
if (!context.mounted) return;
final errorMessage = ref.read(
changePasswordViewModelProvider.select((s) => s.errorMessage),
);
if (errorMessage.isNotEmpty) {
showTopSnackbar(
context,
message: errorMessage,
type: MessageType.error,
);
return;
}
final isComplete = ref.read(
changePasswordViewModelProvider.select((s) => s.isComplete),
);
if (isComplete) {
navigationContract.goTo(AppRoutes.legacyLogin);
}
},
text: context.translate(I18n.save),
color: context.sfColors.legacyPrimary,
),
);
}
}

View File

@@ -0,0 +1,34 @@
import 'dart:async';
import 'package:account/src/core/providers/change_password_repository_provider.dart';
import 'package:account/src/features/change_password/domain/models/entities/change_password_request_entity.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
part 'change_password_controller.g.dart';
@riverpod
class ChangePasswordController extends _$ChangePasswordController {
@override
FutureOr<void> build() {}
Future<void> submit({required String password}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final user = await ref.read(userInfoProvider.future);
await ref.read(changePasswordRepositoryProvider).changePassword(
userId: user.id,
request: ChangePasswordRequestEntity(password: password),
);
unawaited(ref.read(sfTrackingProvider).legacyAccountPasswordChanged());
});
if (state.hasError) {
unawaited(
ref
.read(sfTrackingProvider)
.legacyAccountPasswordChangeFailed(state.error.toString()),
);
}
}
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'change_password_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChangePasswordController)
const changePasswordControllerProvider = ChangePasswordControllerProvider._();
final class ChangePasswordControllerProvider
extends $AsyncNotifierProvider<ChangePasswordController, void> {
const ChangePasswordControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'changePasswordControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$changePasswordControllerHash();
@$internal
@override
ChangePasswordController create() => ChangePasswordController();
}
String _$changePasswordControllerHash() =>
r'45645f3f5759459d1f048b0e8e553b9908e659d9';
abstract class _$ChangePasswordController extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
build();
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleValue(ref, null);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'change_password_visibility_provider.g.dart';
class ChangePasswordVisibility {
const ChangePasswordVisibility({
this.showNew = false,
this.showRepeated = false,
});
final bool showNew;
final bool showRepeated;
ChangePasswordVisibility copyWith({bool? showNew, bool? showRepeated}) {
return ChangePasswordVisibility(
showNew: showNew ?? this.showNew,
showRepeated: showRepeated ?? this.showRepeated,
);
}
}
@riverpod
class ChangePasswordVisibilityNotifier
extends _$ChangePasswordVisibilityNotifier {
@override
ChangePasswordVisibility build() => const ChangePasswordVisibility();
void toggleNew() => state = state.copyWith(showNew: !state.showNew);
void toggleRepeated() =>
state = state.copyWith(showRepeated: !state.showRepeated);
}

View File

@@ -0,0 +1,72 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'change_password_visibility_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChangePasswordVisibilityNotifier)
const changePasswordVisibilityProvider =
ChangePasswordVisibilityNotifierProvider._();
final class ChangePasswordVisibilityNotifierProvider
extends
$NotifierProvider<
ChangePasswordVisibilityNotifier,
ChangePasswordVisibility
> {
const ChangePasswordVisibilityNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'changePasswordVisibilityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$changePasswordVisibilityNotifierHash();
@$internal
@override
ChangePasswordVisibilityNotifier create() =>
ChangePasswordVisibilityNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ChangePasswordVisibility value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ChangePasswordVisibility>(value),
);
}
}
String _$changePasswordVisibilityNotifierHash() =>
r'8b4a81d152e0982675608d0ba3dbd53c93257d17';
abstract class _$ChangePasswordVisibilityNotifier
extends $Notifier<ChangePasswordVisibility> {
ChangePasswordVisibility build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<ChangePasswordVisibility, ChangePasswordVisibility>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ChangePasswordVisibility, ChangePasswordVisibility>,
ChangePasswordVisibility,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,152 +0,0 @@
import 'dart:async';
import 'package:account/src/core/domain/repositories/change_password_repository.dart';
import 'package:account/src/core/providers/change_password_repository_provider.dart';
import 'package:account/src/features/change_password/domain/models/entities/change_password_request_entity.dart';
import 'package:account/src/features/change_password/presentation/state/change_password_view_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
final changePasswordViewModelProvider =
NotifierProvider.autoDispose<
ChangePasswordViewModel,
ChangePasswordViewState
>(ChangePasswordViewModel.new);
class ChangePasswordViewModel extends Notifier<ChangePasswordViewState> {
late final ChangePasswordRepository _changePasswordRepository;
late final SfTrackingRepository _tracking;
late final TextEditingController newPasswordController;
late final TextEditingController repeatPasswordController;
late final TextEditingController passwordController;
@override
ChangePasswordViewState build() {
_changePasswordRepository = ref.read(changePasswordRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
_initControllers();
return const ChangePasswordViewState();
}
void _initControllers() {
newPasswordController = TextEditingController();
newPasswordController.addListener(_onNewPasswordChanged);
repeatPasswordController = TextEditingController();
repeatPasswordController.addListener(_onRepeatPasswordChanged);
ref.onDispose(disposeControllers);
}
void toggleNewPasswordVisibility() {
state = state.copyWith(showNewPassword: !state.showNewPassword);
}
void toggleRepeatedPasswordVisibility() {
state = state.copyWith(showRepeatedPassword: !state.showRepeatedPassword);
}
void _onNewPasswordChanged() {
final value = newPasswordController.text;
if (value == state.newPassword) return;
state = state.copyWith(newPassword: value, errorMessage: '');
}
void _onRepeatPasswordChanged() {
final value = repeatPasswordController.text;
if (value == state.repeatPassword) return;
state = state.copyWith(repeatPassword: value, errorMessage: '');
}
bool _validateForm() {
final upperRegex = RegExp(r'[A-Z]');
final digitRegex = RegExp(r'[0-9]');
final specialRegex = RegExp(r'[!@#$%^&*(),.?":{}|<>\-_+=\[\]\\\/~`]');
final password = state.newPassword.trim();
if (password.isEmpty) {
state = state.copyWith(errorMessage: 'errorMessageNewPasswordIsEmpty');
return false;
}
if (state.repeatPassword.trim().isEmpty) {
state = state.copyWith(errorMessage: 'errorMessageRepeatPasswordIsEmpty');
return false;
}
if (password != state.repeatPassword.trim()) {
state = state.copyWith(errorMessage: 'errorMessagePasswordsDontMatch');
return false;
}
if (password.length < 8) {
state = state.copyWith(errorMessage: 'errorPasswordMinLength');
return false;
}
if (!upperRegex.hasMatch(password)) {
state = state.copyWith(errorMessage: 'errorPasswordUppercase');
return false;
}
if (!digitRegex.hasMatch(password)) {
state = state.copyWith(errorMessage: 'errorPasswordDigits');
return false;
}
if (!specialRegex.hasMatch(password)) {
state = state.copyWith(errorMessage: 'errorPasswordSpecial');
return false;
}
return true;
}
ChangePasswordRequestEntity _toRequest() {
return ChangePasswordRequestEntity(password: state.newPassword.trim());
}
Future<void> submit() async {
if (state.isLoading) return;
if (!_validateForm()) return;
try {
state = state.copyWith(isLoading: true, isComplete: false);
final user = await ref.read(userInfoProvider.future);
final request = _toRequest();
await _changePasswordRepository.changePassword(
userId: user.id,
request: request,
);
unawaited(_tracking.legacyAccountPasswordChanged());
state = state.copyWith(isLoading: false, isComplete: true);
} catch (e) {
if (!ref.mounted) return;
_finishWithError(message: e.toString());
return;
}
}
void _finishWithError({required String message}) {
unawaited(_tracking.legacyAccountPasswordChangeFailed(message));
state = state.copyWith(
isLoading: false,
isComplete: false,
errorMessage: message,
);
}
void disposeControllers() {
newPasswordController.removeListener(_onNewPasswordChanged);
newPasswordController.dispose();
repeatPasswordController.removeListener(_onRepeatPasswordChanged);
repeatPasswordController.dispose();
}
}

View File

@@ -1,16 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'change_password_view_state.freezed.dart';
@freezed
abstract class ChangePasswordViewState with _$ChangePasswordViewState {
const factory ChangePasswordViewState({
@Default(false) bool isLoading,
@Default(false) bool isComplete,
@Default(false) bool showNewPassword,
@Default(false) bool showRepeatedPassword,
@Default('') String newPassword,
@Default('') String repeatPassword,
@Default('') String errorMessage,
}) = _ChangePasswordViewState;
}

View File

@@ -1,289 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'change_password_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChangePasswordViewState {
bool get isLoading; bool get isComplete; bool get showNewPassword; bool get showRepeatedPassword; String get newPassword; String get repeatPassword; String get errorMessage;
/// Create a copy of ChangePasswordViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChangePasswordViewStateCopyWith<ChangePasswordViewState> get copyWith => _$ChangePasswordViewStateCopyWithImpl<ChangePasswordViewState>(this as ChangePasswordViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChangePasswordViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.showNewPassword, showNewPassword) || other.showNewPassword == showNewPassword)&&(identical(other.showRepeatedPassword, showRepeatedPassword) || other.showRepeatedPassword == showRepeatedPassword)&&(identical(other.newPassword, newPassword) || other.newPassword == newPassword)&&(identical(other.repeatPassword, repeatPassword) || other.repeatPassword == repeatPassword)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,isComplete,showNewPassword,showRepeatedPassword,newPassword,repeatPassword,errorMessage);
@override
String toString() {
return 'ChangePasswordViewState(isLoading: $isLoading, isComplete: $isComplete, showNewPassword: $showNewPassword, showRepeatedPassword: $showRepeatedPassword, newPassword: $newPassword, repeatPassword: $repeatPassword, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class $ChangePasswordViewStateCopyWith<$Res> {
factory $ChangePasswordViewStateCopyWith(ChangePasswordViewState value, $Res Function(ChangePasswordViewState) _then) = _$ChangePasswordViewStateCopyWithImpl;
@useResult
$Res call({
bool isLoading, bool isComplete, bool showNewPassword, bool showRepeatedPassword, String newPassword, String repeatPassword, String errorMessage
});
}
/// @nodoc
class _$ChangePasswordViewStateCopyWithImpl<$Res>
implements $ChangePasswordViewStateCopyWith<$Res> {
_$ChangePasswordViewStateCopyWithImpl(this._self, this._then);
final ChangePasswordViewState _self;
final $Res Function(ChangePasswordViewState) _then;
/// Create a copy of ChangePasswordViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? isComplete = null,Object? showNewPassword = null,Object? showRepeatedPassword = null,Object? newPassword = null,Object? repeatPassword = null,Object? errorMessage = null,}) {
return _then(_self.copyWith(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable
as bool,showNewPassword: null == showNewPassword ? _self.showNewPassword : showNewPassword // ignore: cast_nullable_to_non_nullable
as bool,showRepeatedPassword: null == showRepeatedPassword ? _self.showRepeatedPassword : showRepeatedPassword // ignore: cast_nullable_to_non_nullable
as bool,newPassword: null == newPassword ? _self.newPassword : newPassword // ignore: cast_nullable_to_non_nullable
as String,repeatPassword: null == repeatPassword ? _self.repeatPassword : repeatPassword // ignore: cast_nullable_to_non_nullable
as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [ChangePasswordViewState].
extension ChangePasswordViewStatePatterns on ChangePasswordViewState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ChangePasswordViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChangePasswordViewState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ChangePasswordViewState value) $default,){
final _that = this;
switch (_that) {
case _ChangePasswordViewState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ChangePasswordViewState value)? $default,){
final _that = this;
switch (_that) {
case _ChangePasswordViewState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isLoading, bool isComplete, bool showNewPassword, bool showRepeatedPassword, String newPassword, String repeatPassword, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChangePasswordViewState() when $default != null:
return $default(_that.isLoading,_that.isComplete,_that.showNewPassword,_that.showRepeatedPassword,_that.newPassword,_that.repeatPassword,_that.errorMessage);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isLoading, bool isComplete, bool showNewPassword, bool showRepeatedPassword, String newPassword, String repeatPassword, String errorMessage) $default,) {final _that = this;
switch (_that) {
case _ChangePasswordViewState():
return $default(_that.isLoading,_that.isComplete,_that.showNewPassword,_that.showRepeatedPassword,_that.newPassword,_that.repeatPassword,_that.errorMessage);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isLoading, bool isComplete, bool showNewPassword, bool showRepeatedPassword, String newPassword, String repeatPassword, String errorMessage)? $default,) {final _that = this;
switch (_that) {
case _ChangePasswordViewState() when $default != null:
return $default(_that.isLoading,_that.isComplete,_that.showNewPassword,_that.showRepeatedPassword,_that.newPassword,_that.repeatPassword,_that.errorMessage);case _:
return null;
}
}
}
/// @nodoc
class _ChangePasswordViewState implements ChangePasswordViewState {
const _ChangePasswordViewState({this.isLoading = false, this.isComplete = false, this.showNewPassword = false, this.showRepeatedPassword = false, this.newPassword = '', this.repeatPassword = '', this.errorMessage = ''});
@override@JsonKey() final bool isLoading;
@override@JsonKey() final bool isComplete;
@override@JsonKey() final bool showNewPassword;
@override@JsonKey() final bool showRepeatedPassword;
@override@JsonKey() final String newPassword;
@override@JsonKey() final String repeatPassword;
@override@JsonKey() final String errorMessage;
/// Create a copy of ChangePasswordViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChangePasswordViewStateCopyWith<_ChangePasswordViewState> get copyWith => __$ChangePasswordViewStateCopyWithImpl<_ChangePasswordViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChangePasswordViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.showNewPassword, showNewPassword) || other.showNewPassword == showNewPassword)&&(identical(other.showRepeatedPassword, showRepeatedPassword) || other.showRepeatedPassword == showRepeatedPassword)&&(identical(other.newPassword, newPassword) || other.newPassword == newPassword)&&(identical(other.repeatPassword, repeatPassword) || other.repeatPassword == repeatPassword)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,isComplete,showNewPassword,showRepeatedPassword,newPassword,repeatPassword,errorMessage);
@override
String toString() {
return 'ChangePasswordViewState(isLoading: $isLoading, isComplete: $isComplete, showNewPassword: $showNewPassword, showRepeatedPassword: $showRepeatedPassword, newPassword: $newPassword, repeatPassword: $repeatPassword, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class _$ChangePasswordViewStateCopyWith<$Res> implements $ChangePasswordViewStateCopyWith<$Res> {
factory _$ChangePasswordViewStateCopyWith(_ChangePasswordViewState value, $Res Function(_ChangePasswordViewState) _then) = __$ChangePasswordViewStateCopyWithImpl;
@override @useResult
$Res call({
bool isLoading, bool isComplete, bool showNewPassword, bool showRepeatedPassword, String newPassword, String repeatPassword, String errorMessage
});
}
/// @nodoc
class __$ChangePasswordViewStateCopyWithImpl<$Res>
implements _$ChangePasswordViewStateCopyWith<$Res> {
__$ChangePasswordViewStateCopyWithImpl(this._self, this._then);
final _ChangePasswordViewState _self;
final $Res Function(_ChangePasswordViewState) _then;
/// Create a copy of ChangePasswordViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? isComplete = null,Object? showNewPassword = null,Object? showRepeatedPassword = null,Object? newPassword = null,Object? repeatPassword = null,Object? errorMessage = null,}) {
return _then(_ChangePasswordViewState(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable
as bool,showNewPassword: null == showNewPassword ? _self.showNewPassword : showNewPassword // ignore: cast_nullable_to_non_nullable
as bool,showRepeatedPassword: null == showRepeatedPassword ? _self.showRepeatedPassword : showRepeatedPassword // ignore: cast_nullable_to_non_nullable
as bool,newPassword: null == newPassword ? _self.newPassword : newPassword // ignore: cast_nullable_to_non_nullable
as String,repeatPassword: null == repeatPassword ? _self.repeatPassword : repeatPassword // ignore: cast_nullable_to_non_nullable
as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -0,0 +1,116 @@
import 'package:account/src/core/domain/repositories/change_password_repository.dart';
import 'package:account/src/core/providers/change_password_repository_provider.dart';
import 'package:account/src/features/change_password/domain/models/entities/change_password_request_entity.dart';
import 'package:account/src/features/change_password/presentation/providers/change_password_controller.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_shared/testing.dart';
import 'package:sf_tracking/sf_tracking.dart';
class MockChangePasswordRepository extends Mock
implements ChangePasswordRepository {}
class _FakeUserInfoNotifier extends UserInfoNotifier {
_FakeUserInfoNotifier(this._user);
final UserEntity _user;
@override
Future<UserEntity> build() async => _user;
}
const _user = UserEntity(
id: 'user-1',
email: 'user1@test.com',
createdAt: 0,
status: 'active',
role: 'parent',
lastLogin: 0,
currentLogin: 0,
language: 'es',
firstName: 'Owner',
lastName: 'Doe',
hasApiKey: false,
phone: '',
);
void main() {
setUpAll(() {
registerFallbackValue(const ChangePasswordRequestEntity(password: ''));
});
group('ChangePasswordController.submit', () {
test('transitions from loading to AsyncData on success', () async {
final repo = MockChangePasswordRepository();
when(
() => repo.changePassword(
userId: any(named: 'userId'),
request: any(named: 'request'),
),
).thenAnswer((_) async {});
final container = makeContainer(
overrides: [
userInfoProvider.overrideWith(() => _FakeUserInfoNotifier(_user)),
changePasswordRepositoryProvider.overrideWithValue(repo),
sfTrackingProvider.overrideWithValue(
SfTrackingRepository(clients: const []),
),
],
);
addTearDown(container.dispose);
expect(
container.read(changePasswordControllerProvider).isLoading,
isFalse,
);
await container
.read(changePasswordControllerProvider.notifier)
.submit(password: 'Abcd1234!');
final state = container.read(changePasswordControllerProvider);
expect(state, isA<AsyncData<void>>());
expect(state.isLoading, isFalse);
expect(state.error, isNull);
verify(
() => repo.changePassword(
userId: _user.id,
request: const ChangePasswordRequestEntity(password: 'Abcd1234!'),
),
).called(1);
});
test('exposes AsyncError when the repository fails', () async {
final repo = MockChangePasswordRepository();
when(
() => repo.changePassword(
userId: any(named: 'userId'),
request: any(named: 'request'),
),
).thenThrow(const ApiException(message: 'boom', isNetworkError: true));
final container = makeContainer(
overrides: [
userInfoProvider.overrideWith(() => _FakeUserInfoNotifier(_user)),
changePasswordRepositoryProvider.overrideWithValue(repo),
sfTrackingProvider.overrideWithValue(
SfTrackingRepository(clients: const []),
),
],
);
addTearDown(container.dispose);
await container
.read(changePasswordControllerProvider.notifier)
.submit(password: 'Abcd1234!');
final state = container.read(changePasswordControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, isA<ApiException>());
});
});
}

View File

@@ -0,0 +1,51 @@
import 'package:account/src/features/change_password/presentation/providers/change_password_visibility_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sf_shared/testing.dart';
void main() {
group('ChangePasswordVisibilityNotifier', () {
test('defaults to both flags false', () {
final container = makeContainer();
addTearDown(container.dispose);
final state = container.read(changePasswordVisibilityProvider);
expect(state.showNew, isFalse);
expect(state.showRepeated, isFalse);
});
test('toggleNew flips showNew only', () {
final container = makeContainer();
addTearDown(container.dispose);
final notifier = container.read(
changePasswordVisibilityProvider.notifier,
);
notifier.toggleNew();
expect(container.read(changePasswordVisibilityProvider).showNew, isTrue);
expect(
container.read(changePasswordVisibilityProvider).showRepeated,
isFalse,
);
notifier.toggleNew();
expect(container.read(changePasswordVisibilityProvider).showNew, isFalse);
});
test('toggleRepeated flips showRepeated only', () {
final container = makeContainer();
addTearDown(container.dispose);
final notifier = container.read(
changePasswordVisibilityProvider.notifier,
);
notifier.toggleRepeated();
expect(
container.read(changePasswordVisibilityProvider).showRepeated,
isTrue,
);
expect(container.read(changePasswordVisibilityProvider).showNew, isFalse);
});
});
}

View File

@@ -0,0 +1,88 @@
import 'package:account/src/features/change_password/domain/password_validator.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sf_localizations/sf_localizations.dart';
void main() {
group('PasswordValidator.validate', () {
test('returns errorPasswordRequired when password is empty', () {
expect(
PasswordValidator.validate(password: '', repeat: 'Abcd1234!'),
I18n.errorPasswordRequired,
);
});
test('returns errorPasswordRequired when repeat is empty', () {
expect(
PasswordValidator.validate(password: 'Abcd1234!', repeat: ''),
I18n.errorPasswordRequired,
);
});
test('returns errorMessageUnequalPasswords when they differ', () {
expect(
PasswordValidator.validate(
password: 'Abcd1234!',
repeat: 'Abcd1234@',
),
I18n.errorMessageUnequalPasswords,
);
});
test('returns errorMessagePasswordTooShort when under minLength', () {
expect(
PasswordValidator.validate(password: 'Ab1!', repeat: 'Ab1!'),
I18n.errorMessagePasswordTooShort,
);
});
test('returns errorMessagePasswordNoCapitals when missing uppercase', () {
expect(
PasswordValidator.validate(
password: 'abcd1234!',
repeat: 'abcd1234!',
),
I18n.errorMessagePasswordNoCapitals,
);
});
test('returns errorMessagePasswordNoNumbers when missing digit', () {
expect(
PasswordValidator.validate(
password: 'Abcdefgh!',
repeat: 'Abcdefgh!',
),
I18n.errorMessagePasswordNoNumbers,
);
});
test('returns errorMessagePasswordNoSpecialChars when missing special', () {
expect(
PasswordValidator.validate(
password: 'Abcd1234',
repeat: 'Abcd1234',
),
I18n.errorMessagePasswordNoSpecialChars,
);
});
test('returns null when password meets all criteria', () {
expect(
PasswordValidator.validate(
password: 'Abcd1234!',
repeat: 'Abcd1234!',
),
isNull,
);
});
test('trims whitespace before validating', () {
expect(
PasswordValidator.validate(
password: ' Abcd1234! ',
repeat: ' Abcd1234! ',
),
isNull,
);
});
});
}

View File

@@ -54,6 +54,7 @@
"passwordCapital": "ein Großbuchstabe",
"passwordNumber": "eine Zahl",
"passwordSpecial": "Ein Sonderzeichen enthalten",
"passwordMatch": "Passwörter stimmen überein",
"accept": "Akzeptieren",
"errorMessageUnequalPasswords": "Passwörter stimmen nicht überein. versuchen Sie es erneut",
"errorMessagePasswordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",

View File

@@ -54,6 +54,7 @@
"passwordCapital": "One capital letter",
"passwordNumber": "One number",
"passwordSpecial": "One special character",
"passwordMatch": "Passwords match",
"accept": "Accept",
"errorMessageUnequalPasswords": "Passwords don't match. Try again",
"errorMessagePasswordTooShort": "Password must include at least 8 characters",

View File

@@ -54,6 +54,7 @@
"passwordCapital": "Una mayúscula",
"passwordNumber": "Un número",
"passwordSpecial": "Una carácter especial",
"passwordMatch": "Las contraseñas coinciden",
"accept": "Aceptar",
"errorMessageUnequalPasswords": "Las contraseñas no coinciden. Inténtalo de nuevo",
"errorMessagePasswordTooShort": "La contraseña debe tener al menos 8 caracteres",

View File

@@ -54,6 +54,7 @@
"passwordCapital": "une majuscule",
"passwordNumber": "un numéro",
"passwordSpecial": "Un caractère particulier",
"passwordMatch": "Les mots de passe correspondent",
"accept": "Accepter",
"errorMessageUnequalPasswords": "Les mots de passe ne correspondent pas. essayer à nouveau",
"errorMessagePasswordTooShort": "Le mot de passe doit contenir au moins 8 caractères",

View File

@@ -54,6 +54,7 @@
"passwordCapital": "una lettera maiuscola",
"passwordNumber": "un numero",
"passwordSpecial": "Un carattere speciale",
"passwordMatch": "Le password corrispondono",
"accept": "Accettare",
"errorMessageUnequalPasswords": "Le password non corrispondono. riprova",
"errorMessagePasswordTooShort": "La password deve contenere almeno 8 caratteri",

View File

@@ -54,6 +54,7 @@
"passwordCapital": "Una mayúscula",
"passwordNumber": "Um número",
"passwordSpecial": "Um caráter especial",
"passwordMatch": "As palavras-passe coincidem",
"accept": "Aceitar",
"errorMessageUnequalPasswords": "Las contraseñas não é coincidência.",
"errorMessagePasswordTooShort": "A senha deve ter pelo menos 8 caracteres",

View File

@@ -646,6 +646,7 @@ class I18n {
static const String passwordCapital = 'passwordCapital';
static const String passwordLabel = 'passwordLabel';
static const String passwordLength = 'passwordLength';
static const String passwordMatch = 'passwordMatch';
static const String passwordNumber = 'passwordNumber';
static const String passwordRulesSubtitle = 'passwordRulesSubtitle';
static const String passwordSpecial = 'passwordSpecial';