feat(sf_shared): add SfPhoneNumber value object and DeviceContactPicker service

This commit is contained in:
2026-04-15 16:50:12 +02:00
parent cbaff2e763
commit 8c1ca94a08
8 changed files with 479 additions and 0 deletions

View File

@@ -54,3 +54,6 @@ export 'src/domain/entities/mcc_group_entity.dart';
export 'src/domain/entities/wallet_card_entity.dart';
export 'src/domain/entities/wallet_limits_entity.dart';
export 'src/providers/wallet_transactions_provider.dart';
export 'src/domain/value_objects/phone_number.dart';
export 'src/services/device_contact_picker.dart';
export 'src/providers/device_contact_picker_provider.dart';

View File

@@ -0,0 +1,67 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:phone_numbers_parser/phone_numbers_parser.dart' as pnp;
part 'phone_number.freezed.dart';
@freezed
abstract class SfPhoneNumber with _$SfPhoneNumber {
static const String defaultIsoCode = 'ES';
const SfPhoneNumber._();
const factory SfPhoneNumber({
required String dialCode,
required String nationalNumber,
required String isoCode,
}) = _SfPhoneNumber;
String get e164 => '+$dialCode$nationalNumber';
String format() {
try {
final parsed = pnp.PhoneNumber.parse(e164);
return parsed.formatNsn().isEmpty
? e164
: '+$dialCode ${parsed.formatNsn()}';
} catch (_) {
return e164;
}
}
static SfPhoneNumber? tryParse(String raw, {String? defaultIsoCode}) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return null;
try {
final iso = _parseIso(defaultIsoCode);
final parsed = iso != null
? pnp.PhoneNumber.parse(
trimmed,
destinationCountry: iso,
callerCountry: iso,
)
: pnp.PhoneNumber.parse(trimmed);
if (!parsed.isValid()) return null;
return SfPhoneNumber(
dialCode: parsed.countryCode,
nationalNumber: parsed.nsn,
isoCode: parsed.isoCode.name,
);
} catch (_) {
return null;
}
}
static pnp.IsoCode? _parseIso(String? code) {
if (code == null || code.isEmpty) return null;
try {
return pnp.IsoCode.values.firstWhere(
(i) => i.name.toUpperCase() == code.toUpperCase(),
);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,277 @@
// 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 'phone_number.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SfPhoneNumber {
String get dialCode; String get nationalNumber; String get isoCode;
/// Create a copy of SfPhoneNumber
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SfPhoneNumberCopyWith<SfPhoneNumber> get copyWith => _$SfPhoneNumberCopyWithImpl<SfPhoneNumber>(this as SfPhoneNumber, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SfPhoneNumber&&(identical(other.dialCode, dialCode) || other.dialCode == dialCode)&&(identical(other.nationalNumber, nationalNumber) || other.nationalNumber == nationalNumber)&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode));
}
@override
int get hashCode => Object.hash(runtimeType,dialCode,nationalNumber,isoCode);
@override
String toString() {
return 'SfPhoneNumber(dialCode: $dialCode, nationalNumber: $nationalNumber, isoCode: $isoCode)';
}
}
/// @nodoc
abstract mixin class $SfPhoneNumberCopyWith<$Res> {
factory $SfPhoneNumberCopyWith(SfPhoneNumber value, $Res Function(SfPhoneNumber) _then) = _$SfPhoneNumberCopyWithImpl;
@useResult
$Res call({
String dialCode, String nationalNumber, String isoCode
});
}
/// @nodoc
class _$SfPhoneNumberCopyWithImpl<$Res>
implements $SfPhoneNumberCopyWith<$Res> {
_$SfPhoneNumberCopyWithImpl(this._self, this._then);
final SfPhoneNumber _self;
final $Res Function(SfPhoneNumber) _then;
/// Create a copy of SfPhoneNumber
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? dialCode = null,Object? nationalNumber = null,Object? isoCode = null,}) {
return _then(_self.copyWith(
dialCode: null == dialCode ? _self.dialCode : dialCode // ignore: cast_nullable_to_non_nullable
as String,nationalNumber: null == nationalNumber ? _self.nationalNumber : nationalNumber // ignore: cast_nullable_to_non_nullable
as String,isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [SfPhoneNumber].
extension SfPhoneNumberPatterns on SfPhoneNumber {
/// 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( _SfPhoneNumber value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SfPhoneNumber() 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( _SfPhoneNumber value) $default,){
final _that = this;
switch (_that) {
case _SfPhoneNumber():
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( _SfPhoneNumber value)? $default,){
final _that = this;
switch (_that) {
case _SfPhoneNumber() 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 dialCode, String nationalNumber, String isoCode)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SfPhoneNumber() when $default != null:
return $default(_that.dialCode,_that.nationalNumber,_that.isoCode);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 dialCode, String nationalNumber, String isoCode) $default,) {final _that = this;
switch (_that) {
case _SfPhoneNumber():
return $default(_that.dialCode,_that.nationalNumber,_that.isoCode);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 dialCode, String nationalNumber, String isoCode)? $default,) {final _that = this;
switch (_that) {
case _SfPhoneNumber() when $default != null:
return $default(_that.dialCode,_that.nationalNumber,_that.isoCode);case _:
return null;
}
}
}
/// @nodoc
class _SfPhoneNumber extends SfPhoneNumber {
const _SfPhoneNumber({required this.dialCode, required this.nationalNumber, required this.isoCode}): super._();
@override final String dialCode;
@override final String nationalNumber;
@override final String isoCode;
/// Create a copy of SfPhoneNumber
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SfPhoneNumberCopyWith<_SfPhoneNumber> get copyWith => __$SfPhoneNumberCopyWithImpl<_SfPhoneNumber>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SfPhoneNumber&&(identical(other.dialCode, dialCode) || other.dialCode == dialCode)&&(identical(other.nationalNumber, nationalNumber) || other.nationalNumber == nationalNumber)&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode));
}
@override
int get hashCode => Object.hash(runtimeType,dialCode,nationalNumber,isoCode);
@override
String toString() {
return 'SfPhoneNumber(dialCode: $dialCode, nationalNumber: $nationalNumber, isoCode: $isoCode)';
}
}
/// @nodoc
abstract mixin class _$SfPhoneNumberCopyWith<$Res> implements $SfPhoneNumberCopyWith<$Res> {
factory _$SfPhoneNumberCopyWith(_SfPhoneNumber value, $Res Function(_SfPhoneNumber) _then) = __$SfPhoneNumberCopyWithImpl;
@override @useResult
$Res call({
String dialCode, String nationalNumber, String isoCode
});
}
/// @nodoc
class __$SfPhoneNumberCopyWithImpl<$Res>
implements _$SfPhoneNumberCopyWith<$Res> {
__$SfPhoneNumberCopyWithImpl(this._self, this._then);
final _SfPhoneNumber _self;
final $Res Function(_SfPhoneNumber) _then;
/// Create a copy of SfPhoneNumber
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? dialCode = null,Object? nationalNumber = null,Object? isoCode = null,}) {
return _then(_SfPhoneNumber(
dialCode: null == dialCode ? _self.dialCode : dialCode // ignore: cast_nullable_to_non_nullable
as String,nationalNumber: null == nationalNumber ? _self.nationalNumber : nationalNumber // ignore: cast_nullable_to_non_nullable
as String,isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/device_contact_picker.dart';
final deviceContactPickerProvider = Provider<DeviceContactPicker>((_) {
return const DeviceContactPicker();
});

View File

@@ -0,0 +1,113 @@
import 'dart:ui';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import '../domain/value_objects/phone_number.dart';
class DeviceContactResult {
final String displayName;
final String rawNumber;
final SfPhoneNumber? parsedPhone;
const DeviceContactResult({
required this.displayName,
required this.rawNumber,
required this.parsedPhone,
});
}
enum DeviceContactPickOutcome {
success,
cancelled,
noPhone,
permissionDenied,
permissionPermanentlyDenied,
}
class DeviceContactPickResponse {
final DeviceContactPickOutcome outcome;
final DeviceContactResult? data;
const DeviceContactPickResponse._(this.outcome, this.data);
const DeviceContactPickResponse.success(DeviceContactResult data)
: this._(DeviceContactPickOutcome.success, data);
const DeviceContactPickResponse.cancelled()
: this._(DeviceContactPickOutcome.cancelled, null);
const DeviceContactPickResponse.noPhone()
: this._(DeviceContactPickOutcome.noPhone, null);
const DeviceContactPickResponse.permissionDenied()
: this._(DeviceContactPickOutcome.permissionDenied, null);
const DeviceContactPickResponse.permissionPermanentlyDenied()
: this._(DeviceContactPickOutcome.permissionPermanentlyDenied, null);
bool get isSuccess => outcome == DeviceContactPickOutcome.success;
}
class DeviceContactPicker {
const DeviceContactPicker();
/// Opens the native contact picker, handling permission requests.
///
/// When the contact's phone has no international prefix, parsing falls
/// back in this order:
/// 1. The provided [hintIsoCode] (typically the ISO already selected in
/// the form's country picker).
/// 2. The device's locale country code.
/// 3. Null — caller should show [DeviceContactResult.rawNumber] so the
/// user can edit it manually.
Future<DeviceContactPickResponse> pick({String? hintIsoCode}) async {
final status = await Permission.contacts.request();
if (status.isPermanentlyDenied) {
return const DeviceContactPickResponse.permissionPermanentlyDenied();
}
if (!status.isGranted) {
return const DeviceContactPickResponse.permissionDenied();
}
final contact = await FlutterContacts.openExternalPick();
if (contact == null) return const DeviceContactPickResponse.cancelled();
final full = await FlutterContacts.getContact(
contact.id,
withProperties: true,
);
if (full == null || full.phones.isEmpty) {
return const DeviceContactPickResponse.noPhone();
}
final raw = full.phones.first.number;
final parsed =
SfPhoneNumber.tryParse(raw) ??
_parseWithHint(raw, hintIsoCode) ??
_parseWithDeviceLocale(raw);
return DeviceContactPickResponse.success(
DeviceContactResult(
displayName: full.displayName,
rawNumber: raw,
parsedPhone: parsed,
),
);
}
/// Opens the app's system settings so the user can grant the contacts
/// permission manually after a permanent denial.
Future<bool> openSystemSettings() => openAppSettings();
SfPhoneNumber? _parseWithHint(String raw, String? isoCode) {
if (isoCode == null || isoCode.isEmpty) return null;
return SfPhoneNumber.tryParse(raw, defaultIsoCode: isoCode);
}
SfPhoneNumber? _parseWithDeviceLocale(String raw) {
final deviceIso = PlatformDispatcher.instance.locale.countryCode;
if (deviceIso == null || deviceIso.isEmpty) return null;
return SfPhoneNumber.tryParse(raw, defaultIsoCode: deviceIso);
}
}

View File

@@ -36,6 +36,9 @@ dependencies:
json_serializable: ^6.11.2
shared_preferences: ^2.5.5
get_it: ^9.0.5
phone_numbers_parser: ^9.0.3
flutter_contacts: ^1.1.9+2
permission_handler: ^12.0.1
dev_dependencies:
flutter_test: