feat(sf_shared): add SfPhoneNumber value object and DeviceContactPicker service
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/device_contact_picker.dart';
|
||||
|
||||
final deviceContactPickerProvider = Provider<DeviceContactPicker>((_) {
|
||||
return const DeviceContactPicker();
|
||||
});
|
||||
113
packages/sf_shared/lib/src/services/device_contact_picker.dart
Normal file
113
packages/sf_shared/lib/src/services/device_contact_picker.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user