diff --git a/modules/legacy/modules/device_management/lib/src/features/device_management/presentation/providers/call_watch_form_provider.dart b/modules/legacy/modules/device_management/lib/src/features/device_management/presentation/providers/call_watch_form_provider.dart new file mode 100644 index 00000000..d9c53535 --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/device_management/presentation/providers/call_watch_form_provider.dart @@ -0,0 +1,58 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/sf_shared.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'call_watch_form_provider.g.dart'; + +class CallWatchFormState { + const CallWatchFormState({ + this.isoCode = 'ES', + this.errorMessage = '', + }); + + final String isoCode; + final String errorMessage; + + CallWatchFormState copyWith({String? isoCode, String? errorMessage}) { + return CallWatchFormState( + isoCode: isoCode ?? this.isoCode, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} + +@riverpod +class CallWatchForm extends _$CallWatchForm { + @override + CallWatchFormState build() => const CallWatchFormState(); + + void setIsoCode(String isoCode) { + if (isoCode == state.isoCode) return; + state = state.copyWith(isoCode: isoCode, errorMessage: ''); + } + + void clearError() { + if (state.errorMessage.isEmpty) return; + state = state.copyWith(errorMessage: ''); + } + + Future call(String rawPhone) async { + final phone = rawPhone.trim(); + if (phone.isEmpty) { + state = state.copyWith(errorMessage: I18n.errorMessagePhoneIsEmpty); + return; + } + + final parsed = SfPhoneNumber.tryParse(phone, defaultIsoCode: state.isoCode); + if (parsed == null) { + state = state.copyWith(errorMessage: I18n.errorMessagePhoneIsInvalid); + return; + } + + final url = Uri(scheme: 'tel', path: parsed.e164); + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + } +} diff --git a/modules/legacy/modules/device_management/lib/src/features/device_management/presentation/providers/call_watch_form_provider.g.dart b/modules/legacy/modules/device_management/lib/src/features/device_management/presentation/providers/call_watch_form_provider.g.dart new file mode 100644 index 00000000..fb694de1 --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/device_management/presentation/providers/call_watch_form_provider.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'call_watch_form_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CallWatchForm) +const callWatchFormProvider = CallWatchFormProvider._(); + +final class CallWatchFormProvider + extends $NotifierProvider { + const CallWatchFormProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'callWatchFormProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$callWatchFormHash(); + + @$internal + @override + CallWatchForm create() => CallWatchForm(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CallWatchFormState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$callWatchFormHash() => r'1b1aadd777456786b3df5cb9f12b1e95794c7ccc'; + +abstract class _$CallWatchForm extends $Notifier { + CallWatchFormState build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + CallWatchFormState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_model.dart deleted file mode 100644 index af3bed97..00000000 --- a/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_model.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:device_management/src/features/device_management/state/call_watch_view_state.dart'; -import 'package:sf_localizations/sf_localizations.dart'; -import 'package:sf_shared/sf_shared.dart'; -import 'package:url_launcher/url_launcher.dart'; - -final callWatchViewModelProvider = - NotifierProvider.autoDispose( - CallWatchViewModel.new, - ); - -class CallWatchViewModel extends Notifier { - late final TextEditingController phoneController; - - @override - CallWatchViewState build() { - phoneController = TextEditingController(); - phoneController.addListener(_onPhoneChanged); - - ref.onDispose(_disposeControllers); - - return const CallWatchViewState(); - } - - void _onPhoneChanged() { - final text = phoneController.text; - if (text == state.phone) return; - state = state.copyWith(phone: text, errorMessage: ''); - } - - void updateCountry(String isoCode) { - if (isoCode == state.isoCode) return; - state = state.copyWith(isoCode: isoCode, errorMessage: ''); - } - - Future call() async { - final phone = state.phone.trim(); - if (phone.isEmpty) { - state = state.copyWith(errorMessage: I18n.errorMessagePhoneIsEmpty); - return; - } - - final parsed = SfPhoneNumber.tryParse(phone, defaultIsoCode: state.isoCode); - if (parsed == null) { - state = state.copyWith(errorMessage: I18n.errorMessagePhoneIsInvalid); - return; - } - - final url = Uri(scheme: 'tel', path: parsed.e164); - - if (await canLaunchUrl(url)) { - launchUrl(url); - } else { - throw 'Could not launch $url'; - } - } - - void _disposeControllers() { - phoneController.removeListener(_onPhoneChanged); - phoneController.dispose(); - } -} diff --git a/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_state.dart b/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_state.dart deleted file mode 100644 index 4bf15911..00000000 --- a/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_state.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'call_watch_view_state.freezed.dart'; - -@freezed -abstract class CallWatchViewState with _$CallWatchViewState { - const factory CallWatchViewState({ - @Default('ES') String isoCode, - @Default('') String phone, - @Default('') String errorMessage, - }) = _CallWatchViewState; -} diff --git a/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_state.freezed.dart b/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_state.freezed.dart deleted file mode 100644 index d2a2b122..00000000 --- a/modules/legacy/modules/device_management/lib/src/features/device_management/state/call_watch_view_state.freezed.dart +++ /dev/null @@ -1,277 +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 'call_watch_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$CallWatchViewState { - - String get isoCode; String get phone; String get errorMessage; -/// Create a copy of CallWatchViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$CallWatchViewStateCopyWith get copyWith => _$CallWatchViewStateCopyWithImpl(this as CallWatchViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CallWatchViewState&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,isoCode,phone,errorMessage); - -@override -String toString() { - return 'CallWatchViewState(isoCode: $isoCode, phone: $phone, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class $CallWatchViewStateCopyWith<$Res> { - factory $CallWatchViewStateCopyWith(CallWatchViewState value, $Res Function(CallWatchViewState) _then) = _$CallWatchViewStateCopyWithImpl; -@useResult -$Res call({ - String isoCode, String phone, String errorMessage -}); - - - - -} -/// @nodoc -class _$CallWatchViewStateCopyWithImpl<$Res> - implements $CallWatchViewStateCopyWith<$Res> { - _$CallWatchViewStateCopyWithImpl(this._self, this._then); - - final CallWatchViewState _self; - final $Res Function(CallWatchViewState) _then; - -/// Create a copy of CallWatchViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? isoCode = null,Object? phone = null,Object? errorMessage = null,}) { - return _then(_self.copyWith( -isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable -as String,phone: null == phone ? _self.phone : phone // 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 [CallWatchViewState]. -extension CallWatchViewStatePatterns on CallWatchViewState { -/// 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 Function( _CallWatchViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _CallWatchViewState() 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 Function( _CallWatchViewState value) $default,){ -final _that = this; -switch (_that) { -case _CallWatchViewState(): -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? Function( _CallWatchViewState value)? $default,){ -final _that = this; -switch (_that) { -case _CallWatchViewState() 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 Function( String isoCode, String phone, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _CallWatchViewState() when $default != null: -return $default(_that.isoCode,_that.phone,_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 Function( String isoCode, String phone, String errorMessage) $default,) {final _that = this; -switch (_that) { -case _CallWatchViewState(): -return $default(_that.isoCode,_that.phone,_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? Function( String isoCode, String phone, String errorMessage)? $default,) {final _that = this; -switch (_that) { -case _CallWatchViewState() when $default != null: -return $default(_that.isoCode,_that.phone,_that.errorMessage);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _CallWatchViewState implements CallWatchViewState { - const _CallWatchViewState({this.isoCode = 'ES', this.phone = '', this.errorMessage = ''}); - - -@override@JsonKey() final String isoCode; -@override@JsonKey() final String phone; -@override@JsonKey() final String errorMessage; - -/// Create a copy of CallWatchViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$CallWatchViewStateCopyWith<_CallWatchViewState> get copyWith => __$CallWatchViewStateCopyWithImpl<_CallWatchViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallWatchViewState&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,isoCode,phone,errorMessage); - -@override -String toString() { - return 'CallWatchViewState(isoCode: $isoCode, phone: $phone, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class _$CallWatchViewStateCopyWith<$Res> implements $CallWatchViewStateCopyWith<$Res> { - factory _$CallWatchViewStateCopyWith(_CallWatchViewState value, $Res Function(_CallWatchViewState) _then) = __$CallWatchViewStateCopyWithImpl; -@override @useResult -$Res call({ - String isoCode, String phone, String errorMessage -}); - - - - -} -/// @nodoc -class __$CallWatchViewStateCopyWithImpl<$Res> - implements _$CallWatchViewStateCopyWith<$Res> { - __$CallWatchViewStateCopyWithImpl(this._self, this._then); - - final _CallWatchViewState _self; - final $Res Function(_CallWatchViewState) _then; - -/// Create a copy of CallWatchViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? isoCode = null,Object? phone = null,Object? errorMessage = null,}) { - return _then(_CallWatchViewState( -isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable -as String,phone: null == phone ? _self.phone : phone // 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 diff --git a/modules/legacy/modules/device_management/lib/src/features/device_management/widgets/call_watch_dialog.dart b/modules/legacy/modules/device_management/lib/src/features/device_management/widgets/call_watch_dialog.dart index 03bbc71f..82e30e1b 100644 --- a/modules/legacy/modules/device_management/lib/src/features/device_management/widgets/call_watch_dialog.dart +++ b/modules/legacy/modules/device_management/lib/src/features/device_management/widgets/call_watch_dialog.dart @@ -1,24 +1,46 @@ import 'package:design_system/design_system.dart'; +import 'package:device_management/src/features/device_management/presentation/providers/call_watch_form_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:device_management/src/features/device_management/state/call_watch_view_model.dart'; +import 'package:legacy_theme/legacy_theme.dart'; import 'package:sf_localizations/sf_localizations.dart'; import 'package:utils/utils.dart'; -import 'package:legacy_theme/legacy_theme.dart'; -class CallWatchDialog extends ConsumerWidget { +class CallWatchDialog extends ConsumerStatefulWidget { const CallWatchDialog({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _CallWatchDialogState(); +} - final viewModel = ref.read(callWatchViewModelProvider.notifier); - final viewState = ref.watch(callWatchViewModelProvider); +class _CallWatchDialogState extends ConsumerState { + final _phoneController = TextEditingController(); + + @override + void initState() { + super.initState(); + _phoneController.addListener(_onPhoneChanged); + } + + @override + void dispose() { + _phoneController.removeListener(_onPhoneChanged); + _phoneController.dispose(); + super.dispose(); + } + + void _onPhoneChanged() { + ref.read(callWatchFormProvider.notifier).clearError(); + } + + @override + Widget build(BuildContext context) { + final formState = ref.watch(callWatchFormProvider); return Container( padding: SizeUtils.getByScreen( - small: EdgeInsets.symmetric(horizontal: 26, vertical: 20), - big: EdgeInsets.symmetric(horizontal: 24, vertical: 18), + small: const EdgeInsets.symmetric(horizontal: 26, vertical: 20), + big: const EdgeInsets.symmetric(horizontal: 24, vertical: 18), ), width: SizeUtils.getByScreen(small: 390, big: 380), child: Column( @@ -38,9 +60,7 @@ class CallWatchDialog extends ConsumerWidget { Align( alignment: Alignment.centerRight, child: IconButton( - onPressed: () { - Navigator.pop(context); - }, + onPressed: () => Navigator.pop(context), icon: Icon( Icons.close, color: context.sfColors.legacyPrimary, @@ -64,10 +84,14 @@ class CallWatchDialog extends ConsumerWidget { children: [ CountryPrefixPicker( headerText: context.translate(I18n.selectYourCountry), - initialSelection: viewState.isoCode, + initialSelection: formState.isoCode, onChanged: (country) { final code = country.code; - if (code != null) viewModel.updateCountry(code); + if (code != null) { + ref + .read(callWatchFormProvider.notifier) + .setIsoCode(code); + } }, width: 80, backgroundColor: Colors.transparent, @@ -76,18 +100,18 @@ class CallWatchDialog extends ConsumerWidget { SizedBox(width: SizeUtils.getByScreen(small: 8, big: 7)), Expanded( child: CustomTextField( - controller: viewModel.phoneController, + controller: _phoneController, hint: context.translate(I18n.mainContactPhoneNumber), keyboardType: TextInputType.phone, ), ), ], ), - if (viewState.errorMessage.isNotEmpty) + if (formState.errorMessage.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 12), child: Text( - context.translate(viewState.errorMessage), + context.translate(formState.errorMessage), textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.error, @@ -97,7 +121,9 @@ class CallWatchDialog extends ConsumerWidget { ), SizedBox(height: SizeUtils.getByScreen(small: 28, big: 27)), PrimaryButton( - onPressed: viewModel.call, + onPressed: () => ref + .read(callWatchFormProvider.notifier) + .call(_phoneController.text), text: context.translate(I18n.call), color: context.sfColors.legacyPrimary, height: SizeUtils.getByScreen(small: 38, big: 36), diff --git a/modules/legacy/modules/device_management/pubspec.yaml b/modules/legacy/modules/device_management/pubspec.yaml index d6e4ed1c..bbdfb4a8 100644 --- a/modules/legacy/modules/device_management/pubspec.yaml +++ b/modules/legacy/modules/device_management/pubspec.yaml @@ -55,6 +55,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 @@ -76,6 +77,9 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 + build_runner: ^2.7.1 + riverpod_generator: ^3.0.3 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/modules/legacy/modules/device_management/test/features/device_management/call_watch_form_test.dart b/modules/legacy/modules/device_management/test/features/device_management/call_watch_form_test.dart new file mode 100644 index 00000000..d4fe3541 --- /dev/null +++ b/modules/legacy/modules/device_management/test/features/device_management/call_watch_form_test.dart @@ -0,0 +1,67 @@ +import 'package:device_management/src/features/device_management/presentation/providers/call_watch_form_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/testing.dart'; + +void main() { + group('CallWatchForm', () { + test('starts with ES isoCode and no error', () { + final container = makeContainer(); + addTearDown(container.dispose); + + final state = container.read(callWatchFormProvider); + expect(state.isoCode, 'ES'); + expect(state.errorMessage, isEmpty); + }); + + test('setIsoCode updates state and clears error', () { + final container = makeContainer(); + addTearDown(container.dispose); + + final notifier = container.read(callWatchFormProvider.notifier); + notifier.call('invalid'); + expect( + container.read(callWatchFormProvider).errorMessage, + isNotEmpty, + ); + + notifier.setIsoCode('FR'); + final state = container.read(callWatchFormProvider); + expect(state.isoCode, 'FR'); + expect(state.errorMessage, isEmpty); + }); + + test('call sets errorMessagePhoneIsEmpty when phone is blank', () async { + final container = makeContainer(); + addTearDown(container.dispose); + + await container.read(callWatchFormProvider.notifier).call(' '); + + expect( + container.read(callWatchFormProvider).errorMessage, + I18n.errorMessagePhoneIsEmpty, + ); + }); + + test('call sets errorMessagePhoneIsInvalid when phone is unparseable', + () async { + final container = makeContainer(); + addTearDown(container.dispose); + + await container.read(callWatchFormProvider.notifier).call('abc'); + + expect( + container.read(callWatchFormProvider).errorMessage, + I18n.errorMessagePhoneIsInvalid, + ); + }); + + test('clearError is no-op when error already empty', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container.read(callWatchFormProvider.notifier).clearError(); + expect(container.read(callWatchFormProvider).errorMessage, isEmpty); + }); + }); +}