From 9e41090712b54a01047fd2c710bd6245bba98fcc Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Wed, 15 Apr 2026 17:06:51 +0200 Subject: [PATCH] refactor(settings): unify SOS and block_phone sheets with shared ContactFormSheet --- .../widgets/contact_form_sheet.dart | 276 +++++++++++++++++ .../state/block_phone_view_model.dart | 6 + .../new_block_phone_contact_view_model.dart | 121 ++++++++ .../new_block_phone_contact_view_state.dart | 15 + ...lock_phone_contact_view_state.freezed.dart | 283 ++++++++++++++++++ .../widgets/add_contact_sheet.dart | 244 ++------------- .../state/new_sos_contact_view_model.dart | 119 ++++++++ .../state/new_sos_contact_view_state.dart | 14 + .../new_sos_contact_view_state.freezed.dart | 283 ++++++++++++++++++ .../state/sos_contacts_view_model.dart | 6 + .../widgets/add_sos_contact_sheet.dart | 245 ++------------- 11 files changed, 1187 insertions(+), 425 deletions(-) create mode 100644 modules/legacy/modules/settings/lib/src/core/presentation/widgets/contact_form_sheet.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_model.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.freezed.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_model.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.freezed.dart diff --git a/modules/legacy/modules/settings/lib/src/core/presentation/widgets/contact_form_sheet.dart b/modules/legacy/modules/settings/lib/src/core/presentation/widgets/contact_form_sheet.dart new file mode 100644 index 00000000..571ffdca --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/core/presentation/widgets/contact_form_sheet.dart @@ -0,0 +1,276 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:sf_localizations/sf_localizations.dart'; +import 'package:utils/utils.dart'; + +/// Presentational sheet shared by SOS contacts and block-phone whitelist +/// flows. Holds no state and no VM knowledge — the caller wires its own +/// feature VM and passes the pieces down. +class ContactFormSheet extends StatelessWidget { + final String title; + final Color primaryColor; + final TextEditingController nameController; + final TextEditingController phoneController; + final String isoCode; + final bool canSave; + final bool isSubmitting; + final String? phoneError; + final ValueChanged onCountryChanged; + final Future Function() onPickContact; + final VoidCallback onSubmit; + + const ContactFormSheet({ + super.key, + required this.title, + required this.primaryColor, + required this.nameController, + required this.phoneController, + required this.isoCode, + required this.canSave, + required this.isSubmitting, + required this.phoneError, + required this.onCountryChanged, + required this.onPickContact, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: SizeUtils.getByScreen(small: 22, big: 24), + vertical: SizeUtils.getByScreen(small: 16, big: 18), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _DragHandle(), + SizedBox(height: SizeUtils.getByScreen(small: 12, big: 14)), + _Header( + title: title, + primaryColor: primaryColor, + canSave: canSave, + isSubmitting: isSubmitting, + onSubmit: onSubmit, + ), + SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), + _FieldLabel(label: context.translate(I18n.name)), + const SizedBox(height: 8), + TextField( + controller: nameController, + decoration: _inputDecoration( + hintText: context.translate(I18n.contactName), + primaryColor: primaryColor, + ), + textCapitalization: TextCapitalization.words, + ), + SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), + _FieldLabel(label: context.translate(I18n.phone)), + const SizedBox(height: 8), + _PhoneRow( + isoCode: isoCode, + controller: phoneController, + primaryColor: primaryColor, + onCountryChanged: onCountryChanged, + onPickContact: onPickContact, + ), + if (phoneError != null) ...[ + const SizedBox(height: 8), + Text( + context.translate(phoneError!), + style: TextStyle( + color: Colors.red.shade600, + fontSize: SizeUtils.getByScreen(small: 13, big: 13), + ), + ), + ], + SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), + ], + ), + ), + ), + ), + ); + } + +} + +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} + +class _FieldLabel extends StatelessWidget { + final String label; + const _FieldLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Text( + label, + style: TextStyle( + fontSize: SizeUtils.getByScreen(small: 15, big: 16), + fontWeight: FontWeight.w500, + ), + ); + } +} + +class _Header extends StatelessWidget { + final String title; + final Color primaryColor; + final bool canSave; + final bool isSubmitting; + final VoidCallback onSubmit; + + const _Header({ + required this.title, + required this.primaryColor, + required this.canSave, + required this.isSubmitting, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: TextStyle( + fontSize: SizeUtils.getByScreen(small: 20, big: 21), + fontWeight: FontWeight.w600, + color: primaryColor, + ), + ), + TextButton( + onPressed: (canSave && !isSubmitting) ? onSubmit : null, + child: isSubmitting + ? SizedBox( + width: SizeUtils.getByScreen(small: 20, big: 22), + height: SizeUtils.getByScreen(small: 20, big: 22), + child: CircularProgressIndicator( + strokeWidth: 2, + color: primaryColor, + ), + ) + : Text( + context.translate(I18n.save), + style: TextStyle( + color: canSave ? primaryColor : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: SizeUtils.getByScreen(small: 16, big: 17), + ), + ), + ), + ], + ); + } +} + +class _PhoneRow extends StatelessWidget { + final String isoCode; + final TextEditingController controller; + final Color primaryColor; + final ValueChanged onCountryChanged; + final Future Function() onPickContact; + + const _PhoneRow({ + required this.isoCode, + required this.controller, + required this.primaryColor, + required this.onCountryChanged, + required this.onPickContact, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + CountryPrefixPicker( + headerText: context.translate(I18n.selectYourCountry), + initialSelection: isoCode, + onChanged: (country) { + final code = country.code; + if (code != null) onCountryChanged(code); + }, + width: 80, + ), + SizedBox(width: SizeUtils.getByScreen(small: 10, big: 8)), + Expanded( + child: TextField( + controller: controller, + decoration: _inputDecoration( + hintText: context.translate(I18n.phoneNumber), + primaryColor: primaryColor, + ), + keyboardType: TextInputType.phone, + ), + ), + SizedBox(width: SizeUtils.getByScreen(small: 10, big: 8)), + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: primaryColor, + ), + child: IconButton( + onPressed: onPickContact, + icon: Icon( + SFIcons.contactsCircle, + color: Colors.white, + size: SizeUtils.getByScreen(small: 28, big: 26), + ), + ), + ), + ], + ); + } +} + +InputDecoration _inputDecoration({ + required String hintText, + required Color primaryColor, +}) { + return InputDecoration( + hintText: hintText, + border: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + ); +} diff --git a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart index 2e81b7df..900f7957 100644 --- a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart @@ -32,8 +32,10 @@ class BlockPhoneViewModel extends Notifier { if (device == null) return; final contacts = await _repository.getWhitelist(deviceId: device.id); + if (!ref.mounted) return; state = state.copyWith(contacts: contacts, isLoading: false); } catch (e) { + if (!ref.mounted) return; state = state.copyWith( isLoading: false, errorMessage: formatErrorMessage(e), @@ -55,6 +57,7 @@ class BlockPhoneViewModel extends Notifier { deviceId: device.id, contacts: updatedContacts, ); + if (!ref.mounted) return; unawaited( _tracking.legacySettingsBlockPhoneContactAdded( @@ -68,6 +71,7 @@ class BlockPhoneViewModel extends Notifier { successMessage: I18n.numberAdded, ); } catch (e) { + if (!ref.mounted) return; state = state.copyWith( isSaving: false, errorMessage: formatErrorMessage(e), @@ -89,6 +93,7 @@ class BlockPhoneViewModel extends Notifier { deviceId: device.id, contacts: updatedContacts, ); + if (!ref.mounted) return; unawaited( _tracking.legacySettingsBlockPhoneContactRemoved( @@ -102,6 +107,7 @@ class BlockPhoneViewModel extends Notifier { successMessage: I18n.numberRemoved, ); } catch (e) { + if (!ref.mounted) return; state = state.copyWith( isSaving: false, errorMessage: formatErrorMessage(e), diff --git a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_model.dart b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_model.dart new file mode 100644 index 00000000..76654f11 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_model.dart @@ -0,0 +1,121 @@ +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:settings/src/core/domain/entities/contact_list_contact_entity.dart'; +import 'package:settings/src/features/block_phone/presentation/state/block_phone_view_model.dart'; +import 'package:settings/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.dart'; + +final newBlockPhoneContactViewModelProvider = + NotifierProvider.autoDispose< + NewBlockPhoneContactViewModel, + NewBlockPhoneContactViewState + >(NewBlockPhoneContactViewModel.new); + +class NewBlockPhoneContactViewModel + extends Notifier { + late final TextEditingController nameController; + late final TextEditingController phoneController; + + @override + NewBlockPhoneContactViewState build() { + nameController = TextEditingController(); + phoneController = TextEditingController(); + + nameController.addListener(_refreshCanSave); + phoneController.addListener(_onPhoneChanged); + + ref.onDispose(() { + nameController.removeListener(_refreshCanSave); + phoneController.removeListener(_onPhoneChanged); + nameController.dispose(); + phoneController.dispose(); + }); + + return const NewBlockPhoneContactViewState(); + } + + void _refreshCanSave() { + final canSave = + nameController.text.trim().isNotEmpty && + phoneController.text.trim().isNotEmpty; + if (canSave == state.canSave) return; + state = state.copyWith(canSave: canSave); + } + + void _onPhoneChanged() { + _refreshCanSave(); + if (state.phoneError != null) state = state.copyWith(phoneError: null); + } + + void updateCountry(String isoCode) { + if (isoCode == state.isoCode) return; + state = state.copyWith(isoCode: isoCode, phoneError: null); + } + + void clearPermissionBlocked() { + if (state.permissionBlocked) { + state = state.copyWith(permissionBlocked: false); + } + } + + Future openSystemSettings() => + ref.read(deviceContactPickerProvider).openSystemSettings(); + + Future pickContactFromDevice() async { + final response = await ref + .read(deviceContactPickerProvider) + .pick(hintIsoCode: state.isoCode); + + if (response.outcome == + DeviceContactPickOutcome.permissionPermanentlyDenied) { + state = state.copyWith(permissionBlocked: true); + return; + } + + final data = response.data; + if (data == null) return; + + final parsed = data.parsedPhone; + if (parsed != null) { + state = state.copyWith(isoCode: parsed.isoCode, phoneError: null); + phoneController.text = parsed.nationalNumber; + } else { + phoneController.text = data.rawNumber; + state = state.copyWith(phoneError: null); + } + + if (nameController.text.trim().isEmpty) { + nameController.text = data.displayName; + } + } + + Future submit() async { + if (state.isSubmitting || !state.canSave) return false; + + final parsed = SfPhoneNumber.tryParse( + phoneController.text, + defaultIsoCode: state.isoCode, + ); + if (parsed == null) { + state = state.copyWith(phoneError: I18n.errorMessagePhoneIsInvalid); + return false; + } + + state = state.copyWith(isSubmitting: true); + + await ref + .read(blockPhoneViewModelProvider.notifier) + .addContact( + ContactListContactEntity( + name: nameController.text.trim(), + phone: parsed.e164, + ), + ); + + if (!ref.mounted) return false; + state = state.copyWith(isSubmitting: false); + return true; + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.dart b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.dart new file mode 100644 index 00000000..2be6f525 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'new_block_phone_contact_view_state.freezed.dart'; + +@freezed +abstract class NewBlockPhoneContactViewState + with _$NewBlockPhoneContactViewState { + const factory NewBlockPhoneContactViewState({ + @Default('ES') String isoCode, + @Default(false) bool canSave, + @Default(false) bool isSubmitting, + @Default(false) bool permissionBlocked, + String? phoneError, + }) = _NewBlockPhoneContactViewState; +} diff --git a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.freezed.dart b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.freezed.dart new file mode 100644 index 00000000..fc335a94 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/new_block_phone_contact_view_state.freezed.dart @@ -0,0 +1,283 @@ +// 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 'new_block_phone_contact_view_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$NewBlockPhoneContactViewState { + + String get isoCode; bool get canSave; bool get isSubmitting; bool get permissionBlocked; String? get phoneError; +/// Create a copy of NewBlockPhoneContactViewState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewBlockPhoneContactViewStateCopyWith get copyWith => _$NewBlockPhoneContactViewStateCopyWithImpl(this as NewBlockPhoneContactViewState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewBlockPhoneContactViewState&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.canSave, canSave) || other.canSave == canSave)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.permissionBlocked, permissionBlocked) || other.permissionBlocked == permissionBlocked)&&(identical(other.phoneError, phoneError) || other.phoneError == phoneError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isoCode,canSave,isSubmitting,permissionBlocked,phoneError); + +@override +String toString() { + return 'NewBlockPhoneContactViewState(isoCode: $isoCode, canSave: $canSave, isSubmitting: $isSubmitting, permissionBlocked: $permissionBlocked, phoneError: $phoneError)'; +} + + +} + +/// @nodoc +abstract mixin class $NewBlockPhoneContactViewStateCopyWith<$Res> { + factory $NewBlockPhoneContactViewStateCopyWith(NewBlockPhoneContactViewState value, $Res Function(NewBlockPhoneContactViewState) _then) = _$NewBlockPhoneContactViewStateCopyWithImpl; +@useResult +$Res call({ + String isoCode, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError +}); + + + + +} +/// @nodoc +class _$NewBlockPhoneContactViewStateCopyWithImpl<$Res> + implements $NewBlockPhoneContactViewStateCopyWith<$Res> { + _$NewBlockPhoneContactViewStateCopyWithImpl(this._self, this._then); + + final NewBlockPhoneContactViewState _self; + final $Res Function(NewBlockPhoneContactViewState) _then; + +/// Create a copy of NewBlockPhoneContactViewState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isoCode = null,Object? canSave = null,Object? isSubmitting = null,Object? permissionBlocked = null,Object? phoneError = freezed,}) { + return _then(_self.copyWith( +isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable +as String,canSave: null == canSave ? _self.canSave : canSave // ignore: cast_nullable_to_non_nullable +as bool,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable +as bool,permissionBlocked: null == permissionBlocked ? _self.permissionBlocked : permissionBlocked // ignore: cast_nullable_to_non_nullable +as bool,phoneError: freezed == phoneError ? _self.phoneError : phoneError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [NewBlockPhoneContactViewState]. +extension NewBlockPhoneContactViewStatePatterns on NewBlockPhoneContactViewState { +/// 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( _NewBlockPhoneContactViewState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _NewBlockPhoneContactViewState() 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( _NewBlockPhoneContactViewState value) $default,){ +final _that = this; +switch (_that) { +case _NewBlockPhoneContactViewState(): +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( _NewBlockPhoneContactViewState value)? $default,){ +final _that = this; +switch (_that) { +case _NewBlockPhoneContactViewState() 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, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _NewBlockPhoneContactViewState() when $default != null: +return $default(_that.isoCode,_that.canSave,_that.isSubmitting,_that.permissionBlocked,_that.phoneError);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, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError) $default,) {final _that = this; +switch (_that) { +case _NewBlockPhoneContactViewState(): +return $default(_that.isoCode,_that.canSave,_that.isSubmitting,_that.permissionBlocked,_that.phoneError);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, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError)? $default,) {final _that = this; +switch (_that) { +case _NewBlockPhoneContactViewState() when $default != null: +return $default(_that.isoCode,_that.canSave,_that.isSubmitting,_that.permissionBlocked,_that.phoneError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _NewBlockPhoneContactViewState implements NewBlockPhoneContactViewState { + const _NewBlockPhoneContactViewState({this.isoCode = 'ES', this.canSave = false, this.isSubmitting = false, this.permissionBlocked = false, this.phoneError}); + + +@override@JsonKey() final String isoCode; +@override@JsonKey() final bool canSave; +@override@JsonKey() final bool isSubmitting; +@override@JsonKey() final bool permissionBlocked; +@override final String? phoneError; + +/// Create a copy of NewBlockPhoneContactViewState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$NewBlockPhoneContactViewStateCopyWith<_NewBlockPhoneContactViewState> get copyWith => __$NewBlockPhoneContactViewStateCopyWithImpl<_NewBlockPhoneContactViewState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NewBlockPhoneContactViewState&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.canSave, canSave) || other.canSave == canSave)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.permissionBlocked, permissionBlocked) || other.permissionBlocked == permissionBlocked)&&(identical(other.phoneError, phoneError) || other.phoneError == phoneError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isoCode,canSave,isSubmitting,permissionBlocked,phoneError); + +@override +String toString() { + return 'NewBlockPhoneContactViewState(isoCode: $isoCode, canSave: $canSave, isSubmitting: $isSubmitting, permissionBlocked: $permissionBlocked, phoneError: $phoneError)'; +} + + +} + +/// @nodoc +abstract mixin class _$NewBlockPhoneContactViewStateCopyWith<$Res> implements $NewBlockPhoneContactViewStateCopyWith<$Res> { + factory _$NewBlockPhoneContactViewStateCopyWith(_NewBlockPhoneContactViewState value, $Res Function(_NewBlockPhoneContactViewState) _then) = __$NewBlockPhoneContactViewStateCopyWithImpl; +@override @useResult +$Res call({ + String isoCode, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError +}); + + + + +} +/// @nodoc +class __$NewBlockPhoneContactViewStateCopyWithImpl<$Res> + implements _$NewBlockPhoneContactViewStateCopyWith<$Res> { + __$NewBlockPhoneContactViewStateCopyWithImpl(this._self, this._then); + + final _NewBlockPhoneContactViewState _self; + final $Res Function(_NewBlockPhoneContactViewState) _then; + +/// Create a copy of NewBlockPhoneContactViewState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isoCode = null,Object? canSave = null,Object? isSubmitting = null,Object? permissionBlocked = null,Object? phoneError = freezed,}) { + return _then(_NewBlockPhoneContactViewState( +isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable +as String,canSave: null == canSave ? _self.canSave : canSave // ignore: cast_nullable_to_non_nullable +as bool,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable +as bool,permissionBlocked: null == permissionBlocked ? _self.permissionBlocked : permissionBlocked // ignore: cast_nullable_to_non_nullable +as bool,phoneError: freezed == phoneError ? _self.phoneError : phoneError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/widgets/add_contact_sheet.dart b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/widgets/add_contact_sheet.dart index ad59eb1e..e6b8c3de 100644 --- a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/widgets/add_contact_sheet.dart +++ b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/widgets/add_contact_sheet.dart @@ -1,13 +1,10 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sf_localizations/sf_localizations.dart'; -import 'package:utils/utils.dart'; -import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart'; - -import '../state/block_phone_view_model.dart'; +import 'package:settings/src/core/presentation/widgets/contact_form_sheet.dart'; +import 'package:settings/src/features/block_phone/presentation/state/new_block_phone_contact_view_model.dart'; void showAddContactSheet(BuildContext context) { showModalBottomSheet( @@ -18,220 +15,43 @@ void showAddContactSheet(BuildContext context) { ); } -class _AddContactSheet extends ConsumerStatefulWidget { +class _AddContactSheet extends ConsumerWidget { const _AddContactSheet(); @override - ConsumerState<_AddContactSheet> createState() => _AddContactSheetState(); -} - -class _AddContactSheetState extends ConsumerState<_AddContactSheet> { - final _nameController = TextEditingController(); - final _phoneController = TextEditingController(); - - @override - void dispose() { - _nameController.dispose(); - _phoneController.dispose(); - super.dispose(); - } - - bool get _canSave => - _nameController.text.trim().isNotEmpty && - _phoneController.text.trim().isNotEmpty; - - Future _pickContact() async { - final contact = await FlutterContacts.openExternalPick(); - if (contact == null || !mounted) return; - - final fullContact = await FlutterContacts.getContact( - contact.id, - withProperties: true, - ); - if (fullContact == null || fullContact.phones.isEmpty) return; - - _phoneController.text = fullContact.phones.first.number; - - if (_nameController.text.trim().isEmpty) { - _nameController.text = fullContact.displayName; - } - - setState(() {}); - } - - void _submit() { - if (!_canSave) return; - - final vm = ref.read(blockPhoneViewModelProvider.notifier); - vm.addContact( - ContactListContactEntity( - name: _nameController.text.trim(), - phone: _phoneController.text.trim(), - ), - ); - Navigator.pop(context); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = ref.watch(themePortProvider); - final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary); - final isSaving = ref.watch( - blockPhoneViewModelProvider.select((s) => s.isSaving), - ); - final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final vm = ref.read(newBlockPhoneContactViewModelProvider.notifier); + final state = ref.watch(newBlockPhoneContactViewModelProvider); - return Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: SizeUtils.getByScreen(small: 22, big: 24), - vertical: SizeUtils.getByScreen(small: 16, big: 18), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - SizedBox(height: SizeUtils.getByScreen(small: 12, big: 14)), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.translate(I18n.addAllowedNumber), - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 20, big: 21), - fontWeight: FontWeight.w600, - color: primaryColor, - ), - ), - TextButton( - onPressed: _canSave && !isSaving ? _submit : null, - child: isSaving - ? SizedBox( - width: SizeUtils.getByScreen(small: 20, big: 22), - height: SizeUtils.getByScreen(small: 20, big: 22), - child: CircularProgressIndicator( - strokeWidth: 2, - color: primaryColor, - ), - ) - : Text( - context.translate(I18n.save), - style: TextStyle( - color: _canSave ? primaryColor : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: SizeUtils.getByScreen( - small: 16, - big: 17, - ), - ), - ), - ), - ], - ), - SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), - Text( - context.translate(I18n.name), - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 15, big: 16), - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 8), - TextField( - controller: _nameController, - onChanged: (_) => setState(() {}), - decoration: _inputDecoration( - hintText: context.translate(I18n.contactName), - primaryColor: primaryColor, - ), - textCapitalization: TextCapitalization.words, - ), - SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), - Text( - context.translate(I18n.phone), - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 15, big: 16), - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _phoneController, - onChanged: (_) => setState(() {}), - readOnly: true, - decoration: _inputDecoration( - hintText: context.translate(I18n.phoneNumber), - primaryColor: primaryColor, - ), - keyboardType: TextInputType.phone, - ), - ), - SizedBox(width: SizeUtils.getByScreen(small: 10, big: 8)), - DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: primaryColor, - ), - child: IconButton( - onPressed: _pickContact, - icon: Icon( - SFIcons.contactsCircle, - color: Colors.white, - size: SizeUtils.getByScreen(small: 28, big: 26), - ), - ), - ), - ], - ), - SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), - ], - ), - ), - ), - ), + ref.listen( + newBlockPhoneContactViewModelProvider.select((s) => s.permissionBlocked), + (_, blocked) { + if (blocked == true) { + showContactsPermissionDialog( + context, + onOpenSettings: vm.openSystemSettings, + ); + vm.clearPermissionBlocked(); + } + }, ); - } - InputDecoration _inputDecoration({ - required String hintText, - required Color primaryColor, - }) { - return InputDecoration( - hintText: hintText, - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide(color: primaryColor, width: 2), - ), - contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 14), + return ContactFormSheet( + title: context.translate(I18n.addAllowedNumber), + primaryColor: theme.getColorFor(ThemeCode.legacyPrimary), + nameController: vm.nameController, + phoneController: vm.phoneController, + isoCode: state.isoCode, + canSave: state.canSave, + isSubmitting: state.isSubmitting, + phoneError: state.phoneError, + onCountryChanged: vm.updateCountry, + onPickContact: vm.pickContactFromDevice, + onSubmit: () async { + final success = await vm.submit(); + if (success && context.mounted) Navigator.pop(context); + }, ); } } diff --git a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_model.dart b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_model.dart new file mode 100644 index 00000000..da071563 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_model.dart @@ -0,0 +1,119 @@ +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:settings/src/core/domain/entities/contact_list_contact_entity.dart'; +import 'package:settings/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.dart'; +import 'package:settings/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart'; + +final newSosContactViewModelProvider = + NotifierProvider.autoDispose( + NewSosContactViewModel.new, + ); + +class NewSosContactViewModel extends Notifier { + late final TextEditingController nameController; + late final TextEditingController phoneController; + + @override + NewSosContactViewState build() { + nameController = TextEditingController(); + phoneController = TextEditingController(); + + nameController.addListener(_refreshCanSave); + phoneController.addListener(_onPhoneChanged); + + ref.onDispose(() { + nameController.removeListener(_refreshCanSave); + phoneController.removeListener(_onPhoneChanged); + nameController.dispose(); + phoneController.dispose(); + }); + + return const NewSosContactViewState(); + } + + void _refreshCanSave() { + final canSave = + nameController.text.trim().isNotEmpty && + phoneController.text.trim().isNotEmpty; + if (canSave == state.canSave) return; + state = state.copyWith(canSave: canSave); + } + + void _onPhoneChanged() { + _refreshCanSave(); + if (state.phoneError != null) state = state.copyWith(phoneError: null); + } + + void updateCountry(String isoCode) { + if (isoCode == state.isoCode) return; + state = state.copyWith(isoCode: isoCode, phoneError: null); + } + + void clearPermissionBlocked() { + if (state.permissionBlocked) { + state = state.copyWith(permissionBlocked: false); + } + } + + Future openSystemSettings() => + ref.read(deviceContactPickerProvider).openSystemSettings(); + + Future pickContactFromDevice() async { + final response = await ref + .read(deviceContactPickerProvider) + .pick(hintIsoCode: state.isoCode); + + if (response.outcome == + DeviceContactPickOutcome.permissionPermanentlyDenied) { + state = state.copyWith(permissionBlocked: true); + return; + } + + final data = response.data; + if (data == null) return; + + final parsed = data.parsedPhone; + if (parsed != null) { + state = state.copyWith(isoCode: parsed.isoCode, phoneError: null); + phoneController.text = parsed.nationalNumber; + } else { + phoneController.text = data.rawNumber; + state = state.copyWith(phoneError: null); + } + + if (nameController.text.trim().isEmpty) { + nameController.text = data.displayName; + } + } + + Future submit() async { + if (state.isSubmitting || !state.canSave) return false; + + final parsed = SfPhoneNumber.tryParse( + phoneController.text, + defaultIsoCode: state.isoCode, + ); + if (parsed == null) { + state = state.copyWith(phoneError: I18n.errorMessagePhoneIsInvalid); + return false; + } + + state = state.copyWith(isSubmitting: true); + + await ref + .read(sosContactsViewModelProvider.notifier) + .addContact( + ContactListContactEntity( + name: nameController.text.trim(), + phone: parsed.e164, + ), + ); + + if (!ref.mounted) return false; + state = state.copyWith(isSubmitting: false); + return true; + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.dart b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.dart new file mode 100644 index 00000000..e233bfcf --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'new_sos_contact_view_state.freezed.dart'; + +@freezed +abstract class NewSosContactViewState with _$NewSosContactViewState { + const factory NewSosContactViewState({ + @Default('ES') String isoCode, + @Default(false) bool canSave, + @Default(false) bool isSubmitting, + @Default(false) bool permissionBlocked, + String? phoneError, + }) = _NewSosContactViewState; +} diff --git a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.freezed.dart b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.freezed.dart new file mode 100644 index 00000000..23f048a9 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/new_sos_contact_view_state.freezed.dart @@ -0,0 +1,283 @@ +// 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 'new_sos_contact_view_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$NewSosContactViewState { + + String get isoCode; bool get canSave; bool get isSubmitting; bool get permissionBlocked; String? get phoneError; +/// Create a copy of NewSosContactViewState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewSosContactViewStateCopyWith get copyWith => _$NewSosContactViewStateCopyWithImpl(this as NewSosContactViewState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewSosContactViewState&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.canSave, canSave) || other.canSave == canSave)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.permissionBlocked, permissionBlocked) || other.permissionBlocked == permissionBlocked)&&(identical(other.phoneError, phoneError) || other.phoneError == phoneError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isoCode,canSave,isSubmitting,permissionBlocked,phoneError); + +@override +String toString() { + return 'NewSosContactViewState(isoCode: $isoCode, canSave: $canSave, isSubmitting: $isSubmitting, permissionBlocked: $permissionBlocked, phoneError: $phoneError)'; +} + + +} + +/// @nodoc +abstract mixin class $NewSosContactViewStateCopyWith<$Res> { + factory $NewSosContactViewStateCopyWith(NewSosContactViewState value, $Res Function(NewSosContactViewState) _then) = _$NewSosContactViewStateCopyWithImpl; +@useResult +$Res call({ + String isoCode, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError +}); + + + + +} +/// @nodoc +class _$NewSosContactViewStateCopyWithImpl<$Res> + implements $NewSosContactViewStateCopyWith<$Res> { + _$NewSosContactViewStateCopyWithImpl(this._self, this._then); + + final NewSosContactViewState _self; + final $Res Function(NewSosContactViewState) _then; + +/// Create a copy of NewSosContactViewState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isoCode = null,Object? canSave = null,Object? isSubmitting = null,Object? permissionBlocked = null,Object? phoneError = freezed,}) { + return _then(_self.copyWith( +isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable +as String,canSave: null == canSave ? _self.canSave : canSave // ignore: cast_nullable_to_non_nullable +as bool,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable +as bool,permissionBlocked: null == permissionBlocked ? _self.permissionBlocked : permissionBlocked // ignore: cast_nullable_to_non_nullable +as bool,phoneError: freezed == phoneError ? _self.phoneError : phoneError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [NewSosContactViewState]. +extension NewSosContactViewStatePatterns on NewSosContactViewState { +/// 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( _NewSosContactViewState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _NewSosContactViewState() 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( _NewSosContactViewState value) $default,){ +final _that = this; +switch (_that) { +case _NewSosContactViewState(): +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( _NewSosContactViewState value)? $default,){ +final _that = this; +switch (_that) { +case _NewSosContactViewState() 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, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _NewSosContactViewState() when $default != null: +return $default(_that.isoCode,_that.canSave,_that.isSubmitting,_that.permissionBlocked,_that.phoneError);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, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError) $default,) {final _that = this; +switch (_that) { +case _NewSosContactViewState(): +return $default(_that.isoCode,_that.canSave,_that.isSubmitting,_that.permissionBlocked,_that.phoneError);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, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError)? $default,) {final _that = this; +switch (_that) { +case _NewSosContactViewState() when $default != null: +return $default(_that.isoCode,_that.canSave,_that.isSubmitting,_that.permissionBlocked,_that.phoneError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _NewSosContactViewState implements NewSosContactViewState { + const _NewSosContactViewState({this.isoCode = 'ES', this.canSave = false, this.isSubmitting = false, this.permissionBlocked = false, this.phoneError}); + + +@override@JsonKey() final String isoCode; +@override@JsonKey() final bool canSave; +@override@JsonKey() final bool isSubmitting; +@override@JsonKey() final bool permissionBlocked; +@override final String? phoneError; + +/// Create a copy of NewSosContactViewState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$NewSosContactViewStateCopyWith<_NewSosContactViewState> get copyWith => __$NewSosContactViewStateCopyWithImpl<_NewSosContactViewState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NewSosContactViewState&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.canSave, canSave) || other.canSave == canSave)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.permissionBlocked, permissionBlocked) || other.permissionBlocked == permissionBlocked)&&(identical(other.phoneError, phoneError) || other.phoneError == phoneError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isoCode,canSave,isSubmitting,permissionBlocked,phoneError); + +@override +String toString() { + return 'NewSosContactViewState(isoCode: $isoCode, canSave: $canSave, isSubmitting: $isSubmitting, permissionBlocked: $permissionBlocked, phoneError: $phoneError)'; +} + + +} + +/// @nodoc +abstract mixin class _$NewSosContactViewStateCopyWith<$Res> implements $NewSosContactViewStateCopyWith<$Res> { + factory _$NewSosContactViewStateCopyWith(_NewSosContactViewState value, $Res Function(_NewSosContactViewState) _then) = __$NewSosContactViewStateCopyWithImpl; +@override @useResult +$Res call({ + String isoCode, bool canSave, bool isSubmitting, bool permissionBlocked, String? phoneError +}); + + + + +} +/// @nodoc +class __$NewSosContactViewStateCopyWithImpl<$Res> + implements _$NewSosContactViewStateCopyWith<$Res> { + __$NewSosContactViewStateCopyWithImpl(this._self, this._then); + + final _NewSosContactViewState _self; + final $Res Function(_NewSosContactViewState) _then; + +/// Create a copy of NewSosContactViewState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isoCode = null,Object? canSave = null,Object? isSubmitting = null,Object? permissionBlocked = null,Object? phoneError = freezed,}) { + return _then(_NewSosContactViewState( +isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable +as String,canSave: null == canSave ? _self.canSave : canSave // ignore: cast_nullable_to_non_nullable +as bool,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable +as bool,permissionBlocked: null == permissionBlocked ? _self.permissionBlocked : permissionBlocked // ignore: cast_nullable_to_non_nullable +as bool,phoneError: freezed == phoneError ? _self.phoneError : phoneError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart index 3faacb66..15ac426b 100644 --- a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart @@ -34,8 +34,10 @@ class SosContactsViewModel extends Notifier { final contacts = await _repository.getEmergencyContacts( deviceId: device.id, ); + if (!ref.mounted) return; state = state.copyWith(contacts: contacts, isLoading: false); } catch (e) { + if (!ref.mounted) return; state = state.copyWith( isLoading: false, errorMessage: formatErrorMessage(e), @@ -57,6 +59,7 @@ class SosContactsViewModel extends Notifier { deviceId: device.id, contacts: updatedContacts, ); + if (!ref.mounted) return; unawaited( _tracking.legacySettingsSosContactAdded( @@ -70,6 +73,7 @@ class SosContactsViewModel extends Notifier { successMessage: I18n.sosNumberAdded, ); } catch (e) { + if (!ref.mounted) return; state = state.copyWith( isSaving: false, errorMessage: formatErrorMessage(e), @@ -91,6 +95,7 @@ class SosContactsViewModel extends Notifier { deviceId: device.id, contacts: updatedContacts, ); + if (!ref.mounted) return; unawaited( _tracking.legacySettingsSosContactRemoved( @@ -104,6 +109,7 @@ class SosContactsViewModel extends Notifier { successMessage: I18n.sosNumberRemoved, ); } catch (e) { + if (!ref.mounted) return; state = state.copyWith( isSaving: false, errorMessage: formatErrorMessage(e), diff --git a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/widgets/add_sos_contact_sheet.dart b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/widgets/add_sos_contact_sheet.dart index 18ef4978..2f0c43fd 100644 --- a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/widgets/add_sos_contact_sheet.dart +++ b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/widgets/add_sos_contact_sheet.dart @@ -1,13 +1,10 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sf_localizations/sf_localizations.dart'; -import 'package:utils/utils.dart'; -import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart'; - -import '../state/sos_contacts_view_model.dart'; +import 'package:settings/src/core/presentation/widgets/contact_form_sheet.dart'; +import 'package:settings/src/features/sos_contacts/presentation/state/new_sos_contact_view_model.dart'; void showAddSosContactSheet(BuildContext context) { showModalBottomSheet( @@ -18,221 +15,43 @@ void showAddSosContactSheet(BuildContext context) { ); } -class _AddSosContactSheet extends ConsumerStatefulWidget { +class _AddSosContactSheet extends ConsumerWidget { const _AddSosContactSheet(); @override - ConsumerState<_AddSosContactSheet> createState() => - _AddSosContactSheetState(); -} - -class _AddSosContactSheetState extends ConsumerState<_AddSosContactSheet> { - final _nameController = TextEditingController(); - final _phoneController = TextEditingController(); - - @override - void dispose() { - _nameController.dispose(); - _phoneController.dispose(); - super.dispose(); - } - - bool get _canSave => - _nameController.text.trim().isNotEmpty && - _phoneController.text.trim().isNotEmpty; - - Future _pickContact() async { - final contact = await FlutterContacts.openExternalPick(); - if (contact == null || !mounted) return; - - final fullContact = await FlutterContacts.getContact( - contact.id, - withProperties: true, - ); - if (fullContact == null || fullContact.phones.isEmpty) return; - - _phoneController.text = fullContact.phones.first.number; - - if (_nameController.text.trim().isEmpty) { - _nameController.text = fullContact.displayName; - } - - setState(() {}); - } - - void _submit() { - if (!_canSave) return; - - final vm = ref.read(sosContactsViewModelProvider.notifier); - vm.addContact( - ContactListContactEntity( - name: _nameController.text.trim(), - phone: _phoneController.text.trim(), - ), - ); - Navigator.pop(context); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = ref.watch(themePortProvider); - final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary); - final isSaving = ref.watch( - sosContactsViewModelProvider.select((s) => s.isSaving), - ); - final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final vm = ref.read(newSosContactViewModelProvider.notifier); + final state = ref.watch(newSosContactViewModelProvider); - return Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: SizeUtils.getByScreen(small: 22, big: 24), - vertical: SizeUtils.getByScreen(small: 16, big: 18), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - SizedBox(height: SizeUtils.getByScreen(small: 12, big: 14)), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.translate(I18n.addSosContact), - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 20, big: 21), - fontWeight: FontWeight.w600, - color: primaryColor, - ), - ), - TextButton( - onPressed: _canSave && !isSaving ? _submit : null, - child: isSaving - ? SizedBox( - width: SizeUtils.getByScreen(small: 20, big: 22), - height: SizeUtils.getByScreen(small: 20, big: 22), - child: CircularProgressIndicator( - strokeWidth: 2, - color: primaryColor, - ), - ) - : Text( - context.translate(I18n.save), - style: TextStyle( - color: _canSave ? primaryColor : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: SizeUtils.getByScreen( - small: 16, - big: 17, - ), - ), - ), - ), - ], - ), - SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), - Text( - context.translate(I18n.name), - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 15, big: 16), - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 8), - TextField( - controller: _nameController, - onChanged: (_) => setState(() {}), - decoration: _inputDecoration( - hintText: context.translate(I18n.contactName), - primaryColor: primaryColor, - ), - textCapitalization: TextCapitalization.words, - ), - SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), - Text( - context.translate(I18n.phone), - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 15, big: 16), - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _phoneController, - onChanged: (_) => setState(() {}), - readOnly: true, - decoration: _inputDecoration( - hintText: context.translate(I18n.phoneNumber), - primaryColor: primaryColor, - ), - keyboardType: TextInputType.phone, - ), - ), - SizedBox(width: SizeUtils.getByScreen(small: 10, big: 8)), - DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: primaryColor, - ), - child: IconButton( - onPressed: _pickContact, - icon: Icon( - SFIcons.contactsCircle, - color: Colors.white, - size: SizeUtils.getByScreen(small: 28, big: 26), - ), - ), - ), - ], - ), - SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), - ], - ), - ), - ), - ), + ref.listen( + newSosContactViewModelProvider.select((s) => s.permissionBlocked), + (_, blocked) { + if (blocked == true) { + showContactsPermissionDialog( + context, + onOpenSettings: vm.openSystemSettings, + ); + vm.clearPermissionBlocked(); + } + }, ); - } - InputDecoration _inputDecoration({ - required String hintText, - required Color primaryColor, - }) { - return InputDecoration( - hintText: hintText, - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide(color: primaryColor, width: 2), - ), - contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 14), + return ContactFormSheet( + title: context.translate(I18n.addSosContact), + primaryColor: theme.getColorFor(ThemeCode.legacyPrimary), + nameController: vm.nameController, + phoneController: vm.phoneController, + isoCode: state.isoCode, + canSave: state.canSave, + isSubmitting: state.isSubmitting, + phoneError: state.phoneError, + onCountryChanged: vm.updateCountry, + onPickContact: vm.pickContactFromDevice, + onSubmit: () async { + final success = await vm.submit(); + if (success && context.mounted) Navigator.pop(context); + }, ); } }