refactor(legacy-customer-service): migrate contact form to AsyncNotifier

This commit is contained in:
2026-04-21 23:45:41 +02:00
parent b8ac786146
commit 417b6660fc
9 changed files with 287 additions and 600 deletions

View File

@@ -0,0 +1,17 @@
import 'package:sf_localizations/sf_localizations.dart';
class EmailValidator {
const EmailValidator._();
static final _regex = RegExp(
r'^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$',
caseSensitive: false,
);
static String? validate(String value) {
final email = value.trim();
if (email.isEmpty) return I18n.errorEmailRequired;
if (!_regex.hasMatch(email)) return I18n.errorEmailInvalid;
return null;
}
}

View File

@@ -1,21 +1,78 @@
import 'package:customer_service/src/presentation/state/contact_view_model.dart';
import 'package:customer_service/src/domain/email_validator.dart';
import 'package:customer_service/src/presentation/providers/contact_controller.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 ContactScreen extends ConsumerWidget {
class ContactScreen extends ConsumerStatefulWidget {
final NavigationContract navigationContract;
const ContactScreen({super.key, required this.navigationContract});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ContactScreen> createState() => _ContactScreenState();
}
final viewModel = ref.read(contactViewModelProvider.notifier);
class _ContactScreenState extends ConsumerState<ContactScreen> {
late final TextEditingController _nameController;
late final TextEditingController _emailController;
late final TextEditingController _subjectController;
late final TextEditingController _bodyController;
String _country = '';
String _channel = '';
String? _localError;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_emailController = TextEditingController();
_subjectController = TextEditingController();
_bodyController = TextEditingController();
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_subjectController.dispose();
_bodyController.dispose();
super.dispose();
}
void _onSend() {
final emailError = EmailValidator.validate(_emailController.text);
if (emailError != null) {
setState(() => _localError = emailError);
return;
}
setState(() => _localError = null);
ref.read(contactControllerProvider.notifier).sendEmail(
country: _country,
channel: _channel,
name: _nameController.text,
email: _emailController.text.trim(),
subject: _subjectController.text,
message: _bodyController.text,
);
}
@override
Widget build(BuildContext context) {
ref.listen(contactControllerProvider, (_, next) {
next.showErrorOn(context);
});
final isLoading = ref.watch(
contactControllerProvider.select((s) => s.isLoading),
);
return LegacyPageLayout(
title: context.translate(I18n.contactTitle),
@@ -26,33 +83,76 @@ class ContactScreen extends ConsumerWidget {
child: SingleChildScrollView(
child: Column(
children: [
const _CountrySection(),
_CountrySection(
onChanged: (value) => setState(() => _country = value),
),
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
const _ChannelSection(),
_ChannelSection(
onChanged: (value) => setState(() => _channel = value),
),
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
const _NameSection(),
CustomTextField(
controller: _nameController,
hint: context.translate(I18n.enterName),
),
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
const _EmailSection(),
CustomTextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
hint: context.translate(I18n.enterEmail),
),
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
const _SubjectSection(),
CustomTextField(
controller: _subjectController,
hint: context.translate(I18n.enterSubject),
),
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
_MessageSection(onSubmit: viewModel.sendEmail),
const _ErrorMessageSection(),
CustomTextField(
controller: _bodyController,
keyboardType: TextInputType.multiline,
hint: context.translate(I18n.enterMessage),
lines: 8,
onSubmitted: (_) => _onSend(),
),
if (_localError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
context.translate(_localError!),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
),
),
),
footer: _SendSection(onSend: viewModel.sendEmail),
footer: Container(
padding: SizeUtils.getByScreen(
small: const EdgeInsets.symmetric(horizontal: 38, vertical: 14),
big: const EdgeInsets.symmetric(horizontal: 36, vertical: 12),
),
child: PrimaryButton(
onPressed: isLoading ? null : _onSend,
text: context.translate(I18n.sendEmail),
color: context.sfColors.legacyPrimary,
),
),
);
}
}
class _CountrySection extends ConsumerWidget {
const _CountrySection();
class _CountrySection extends StatelessWidget {
final ValueChanged<String> onChanged;
const _CountrySection({required this.onChanged});
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<String> countries = [
Widget build(BuildContext context) {
final countries = [
'España',
'Portugal',
'France',
@@ -61,149 +161,32 @@ class _CountrySection extends ConsumerWidget {
context.translate(I18n.other),
];
final vm = ref.read(contactViewModelProvider.notifier);
return CustomDropdown(
items: countries.map(Text.new).toList(growable: false),
onChanged: (x) {
vm.setCountry(x);
},
onChanged: (value) => onChanged(value as String),
hint: context.translate(I18n.selectCountry),
);
}
}
class _ChannelSection extends ConsumerWidget {
const _ChannelSection();
class _ChannelSection extends StatelessWidget {
final ValueChanged<String> onChanged;
const _ChannelSection({required this.onChanged});
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<String> channels = [
Widget build(BuildContext context) {
final channels = [
context.translate(I18n.channelOnline),
context.translate(I18n.channelAmazon),
context.translate(I18n.channelStore),
context.translate(I18n.other),
];
final vm = ref.read(contactViewModelProvider.notifier);
return CustomDropdown(
items: channels.map(Text.new).toList(growable: false),
onChanged: (x) {
vm.setChannel(x);
},
onChanged: (value) => onChanged(value as String),
hint: context.translate(I18n.selectChannel),
);
}
}
class _NameSection extends ConsumerWidget {
const _NameSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(contactViewModelProvider.notifier);
return CustomTextField(
controller: vm.nameController,
hint: context.translate(I18n.enterName),
);
}
}
class _EmailSection extends ConsumerWidget {
const _EmailSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(contactViewModelProvider.notifier);
return CustomTextField(
controller: vm.emailController,
keyboardType: TextInputType.emailAddress,
hint: context.translate(I18n.enterEmail),
);
}
}
class _SubjectSection extends ConsumerWidget {
const _SubjectSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(contactViewModelProvider.notifier);
return CustomTextField(
controller: vm.subjectController,
hint: context.translate(I18n.enterSubject),
);
}
}
class _MessageSection extends ConsumerWidget {
final VoidCallback onSubmit;
const _MessageSection({required this.onSubmit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(contactViewModelProvider.notifier);
return CustomTextField(
controller: vm.bodyController,
keyboardType: TextInputType.multiline,
hint: context.translate(I18n.enterMessage),
lines: 8,
onSubmitted: (_) => onSubmit(),
);
}
}
class _ErrorMessageSection extends ConsumerWidget {
const _ErrorMessageSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewState = ref.watch(contactViewModelProvider);
if (viewState.errorMessage.isNotEmpty) {
return Column(
children: [
const SizedBox(height: 4),
Text(
viewState.errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
);
} else {
return SizedBox.shrink();
}
}
}
class _SendSection extends ConsumerWidget {
final VoidCallback onSend;
const _SendSection({required this.onSend});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: SizeUtils.getByScreen(
small: EdgeInsets.symmetric(horizontal: 38, vertical: 14),
big: EdgeInsets.symmetric(horizontal: 36, vertical: 12),
),
child: PrimaryButton(
onPressed: onSend,
text: context.translate(I18n.sendEmail),
color: Theme.of(context).colorScheme.primary,
),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:url_launcher/url_launcher.dart';
part 'contact_controller.g.dart';
@riverpod
class ContactController extends _$ContactController {
@override
FutureOr<void> build() {}
Future<void> sendEmail({
required String country,
required String channel,
required String name,
required String email,
required String subject,
required String message,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
unawaited(
ref.read(sfTrackingProvider).legacySupportContactInitiated(
channel: 'email',
country: country.isEmpty ? 'unknown' : country,
),
);
final body =
'country:$country\n'
'Purchase channel:$channel\n'
'name:$name\n'
'email:$email\n'
'$message';
final url = Uri.parse(
'mailto:${BrandLinks.supportEmail}'
'?from=$email&subject=$subject&body=$body',
);
if (!await launchUrl(url)) {
throw Exception('Could not launch $url');
}
});
}
}

View File

@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'contact_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ContactController)
const contactControllerProvider = ContactControllerProvider._();
final class ContactControllerProvider
extends $AsyncNotifierProvider<ContactController, void> {
const ContactControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'contactControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$contactControllerHash();
@$internal
@override
ContactController create() => ContactController();
}
String _$contactControllerHash() => r'5c3ab6fe20cc95013f73788332a2707de7e0f6de';
abstract class _$ContactController 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

@@ -1,150 +0,0 @@
import 'dart:async';
import 'package:customer_service/src/presentation/state/contact_view_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:url_launcher/url_launcher.dart';
final contactViewModelProvider =
NotifierProvider.autoDispose<ContactViewModel, ContactViewState>(
ContactViewModel.new,
);
class ContactViewModel extends Notifier<ContactViewState> {
late final TextEditingController nameController;
late final TextEditingController emailController;
late final TextEditingController subjectController;
late final TextEditingController bodyController;
late final SfTrackingRepository _tracking;
static final RegExp _emailRegex = RegExp(
r'^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$',
caseSensitive: false,
);
@override
ContactViewState build() {
_tracking = ref.read(sfTrackingProvider);
nameController = TextEditingController();
emailController = TextEditingController();
subjectController = TextEditingController();
bodyController = TextEditingController();
nameController.addListener(_onNameChanged);
emailController.addListener(_onEmailChanged);
subjectController.addListener(_onSubjectChanged);
bodyController.addListener(_onBodyChanged);
ref.onDispose(disposeControllers);
return const ContactViewState();
}
void setCountry(String value) {
if (value == state.country) return;
state = state.copyWith(country: value, errorMessage: '');
}
void setChannel(String value) {
if (value == state.channel) return;
state = state.copyWith(channel: value, errorMessage: '');
}
void _onNameChanged() {
final text = nameController.text;
if (text == state.name) return;
state = state.copyWith(name: text, errorMessage: '');
}
void _onEmailChanged() {
final text = emailController.text;
if (text == state.email) return;
state = state.copyWith(email: text, errorMessage: '');
state = state.copyWith(emailError: _emailErrorFor(text));
}
bool _isValidEmail(String email) => _emailRegex.hasMatch(email);
String _emailErrorFor(String value) {
final email = value.trim();
if (email.isEmpty) return I18n.errorEmailRequired;
if (!_isValidEmail(email)) return I18n.errorEmailInvalid;
return '';
}
void _onSubjectChanged() {
final text = subjectController.text;
if (text == state.subject) return;
state = state.copyWith(subject: text, errorMessage: '');
}
void _onBodyChanged() {
final text = bodyController.text;
if (text == state.message) return;
state = state.copyWith(message: text, errorMessage: '');
}
void sendEmail() async {
final receiver = BrandLinks.supportEmail;
final name = state.name;
final country = state.country;
final channel = state.channel;
final sender = state.email;
final subject = state.subject;
final message = state.message;
if (sender.isEmpty) {
state = state.copyWith(errorMessage: I18n.errorEmailRequired);
return;
}
if (!_isValidEmail(sender)) {
state = state.copyWith(errorMessage: I18n.errorEmailInvalid);
return;
}
final body =
'country:$country\n'
'Purchase channel:$channel\n'
'name:$name\n'
'email:$sender\n'
'$message';
final Uri url = Uri.parse(
'mailto:$receiver?from=$sender&subject=$subject&body=$body',
);
unawaited(
_tracking.legacySupportContactInitiated(
channel: 'email',
country: country.isEmpty ? 'unknown' : country,
),
);
if (!await launchUrl(url)) {
throw Exception('Could not launch $url');
}
}
void disposeControllers() {
nameController.removeListener(_onNameChanged);
emailController.removeListener(_onEmailChanged);
subjectController.removeListener(_onSubjectChanged);
bodyController.removeListener(_onBodyChanged);
nameController.dispose();
emailController.dispose();
subjectController.dispose();
bodyController.dispose();
}
}

View File

@@ -1,17 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'contact_view_state.freezed.dart';
@freezed
abstract class ContactViewState with _$ContactViewState {
const factory ContactViewState({
@Default('') String country,
@Default('') String channel,
@Default('') String name,
@Default('') String email,
@Default('') String subject,
@Default('') String message,
@Default('') String errorMessage,
@Default('') String emailError,
}) = _ContactViewState;
}

View File

@@ -1,292 +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 'contact_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ContactViewState {
String get country; String get channel; String get name; String get email; String get subject; String get message; String get errorMessage; String get emailError;
/// Create a copy of ContactViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ContactViewStateCopyWith<ContactViewState> get copyWith => _$ContactViewStateCopyWithImpl<ContactViewState>(this as ContactViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ContactViewState&&(identical(other.country, country) || other.country == country)&&(identical(other.channel, channel) || other.channel == channel)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.subject, subject) || other.subject == subject)&&(identical(other.message, message) || other.message == message)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.emailError, emailError) || other.emailError == emailError));
}
@override
int get hashCode => Object.hash(runtimeType,country,channel,name,email,subject,message,errorMessage,emailError);
@override
String toString() {
return 'ContactViewState(country: $country, channel: $channel, name: $name, email: $email, subject: $subject, message: $message, errorMessage: $errorMessage, emailError: $emailError)';
}
}
/// @nodoc
abstract mixin class $ContactViewStateCopyWith<$Res> {
factory $ContactViewStateCopyWith(ContactViewState value, $Res Function(ContactViewState) _then) = _$ContactViewStateCopyWithImpl;
@useResult
$Res call({
String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError
});
}
/// @nodoc
class _$ContactViewStateCopyWithImpl<$Res>
implements $ContactViewStateCopyWith<$Res> {
_$ContactViewStateCopyWithImpl(this._self, this._then);
final ContactViewState _self;
final $Res Function(ContactViewState) _then;
/// Create a copy of ContactViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? country = null,Object? channel = null,Object? name = null,Object? email = null,Object? subject = null,Object? message = null,Object? errorMessage = null,Object? emailError = null,}) {
return _then(_self.copyWith(
country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable
as String,channel: null == channel ? _self.channel : channel // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String,subject: null == subject ? _self.subject : subject // ignore: cast_nullable_to_non_nullable
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [ContactViewState].
extension ContactViewStatePatterns on ContactViewState {
/// 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( _ContactViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ContactViewState() 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( _ContactViewState value) $default,){
final _that = this;
switch (_that) {
case _ContactViewState():
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( _ContactViewState value)? $default,){
final _that = this;
switch (_that) {
case _ContactViewState() 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( String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ContactViewState() when $default != null:
return $default(_that.country,_that.channel,_that.name,_that.email,_that.subject,_that.message,_that.errorMessage,_that.emailError);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( String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError) $default,) {final _that = this;
switch (_that) {
case _ContactViewState():
return $default(_that.country,_that.channel,_that.name,_that.email,_that.subject,_that.message,_that.errorMessage,_that.emailError);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( String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError)? $default,) {final _that = this;
switch (_that) {
case _ContactViewState() when $default != null:
return $default(_that.country,_that.channel,_that.name,_that.email,_that.subject,_that.message,_that.errorMessage,_that.emailError);case _:
return null;
}
}
}
/// @nodoc
class _ContactViewState implements ContactViewState {
const _ContactViewState({this.country = '', this.channel = '', this.name = '', this.email = '', this.subject = '', this.message = '', this.errorMessage = '', this.emailError = ''});
@override@JsonKey() final String country;
@override@JsonKey() final String channel;
@override@JsonKey() final String name;
@override@JsonKey() final String email;
@override@JsonKey() final String subject;
@override@JsonKey() final String message;
@override@JsonKey() final String errorMessage;
@override@JsonKey() final String emailError;
/// Create a copy of ContactViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ContactViewStateCopyWith<_ContactViewState> get copyWith => __$ContactViewStateCopyWithImpl<_ContactViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ContactViewState&&(identical(other.country, country) || other.country == country)&&(identical(other.channel, channel) || other.channel == channel)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.subject, subject) || other.subject == subject)&&(identical(other.message, message) || other.message == message)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.emailError, emailError) || other.emailError == emailError));
}
@override
int get hashCode => Object.hash(runtimeType,country,channel,name,email,subject,message,errorMessage,emailError);
@override
String toString() {
return 'ContactViewState(country: $country, channel: $channel, name: $name, email: $email, subject: $subject, message: $message, errorMessage: $errorMessage, emailError: $emailError)';
}
}
/// @nodoc
abstract mixin class _$ContactViewStateCopyWith<$Res> implements $ContactViewStateCopyWith<$Res> {
factory _$ContactViewStateCopyWith(_ContactViewState value, $Res Function(_ContactViewState) _then) = __$ContactViewStateCopyWithImpl;
@override @useResult
$Res call({
String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError
});
}
/// @nodoc
class __$ContactViewStateCopyWithImpl<$Res>
implements _$ContactViewStateCopyWith<$Res> {
__$ContactViewStateCopyWithImpl(this._self, this._then);
final _ContactViewState _self;
final $Res Function(_ContactViewState) _then;
/// Create a copy of ContactViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? country = null,Object? channel = null,Object? name = null,Object? email = null,Object? subject = null,Object? message = null,Object? errorMessage = null,Object? emailError = null,}) {
return _then(_ContactViewState(
country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable
as String,channel: null == channel ? _self.channel : channel // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String,subject: null == subject ? _self.subject : subject // ignore: cast_nullable_to_non_nullable
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -53,6 +53,7 @@ dependencies:
get_it: ^9.0.5
go_router: ^17.0.0
flutter_riverpod: ^3.0.3
riverpod_annotation: ^3.0.3
freezed_annotation: ^3.1.0
freezed: ^3.2.3
dio: ^5.9.2
@@ -70,6 +71,9 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
riverpod_generator: ^3.0.3
build_runner: ^2.7.1
mocktail: ^1.0.4
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@@ -0,0 +1,38 @@
import 'package:customer_service/src/domain/email_validator.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sf_localizations/sf_localizations.dart';
void main() {
group('EmailValidator.validate', () {
test('returns errorEmailRequired when value is empty', () {
expect(EmailValidator.validate(''), I18n.errorEmailRequired);
});
test('returns errorEmailRequired when value is only whitespace', () {
expect(EmailValidator.validate(' '), I18n.errorEmailRequired);
});
test('returns errorEmailInvalid when value lacks @ sign', () {
expect(EmailValidator.validate('notanemail'), I18n.errorEmailInvalid);
});
test('returns errorEmailInvalid when domain is missing', () {
expect(EmailValidator.validate('user@'), I18n.errorEmailInvalid);
});
test('returns errorEmailInvalid when TLD is missing', () {
expect(
EmailValidator.validate('user@example'),
I18n.errorEmailInvalid,
);
});
test('returns null for a well-formed email', () {
expect(EmailValidator.validate('user@example.com'), isNull);
});
test('trims whitespace before validating', () {
expect(EmailValidator.validate(' user@example.com '), isNull);
});
});
}