refactor(settings): unify SOS and block_phone sheets with shared ContactFormSheet

This commit is contained in:
2026-04-15 17:06:51 +02:00
parent 648d0fc04b
commit 9e41090712
11 changed files with 1187 additions and 425 deletions

View File

@@ -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<String> onCountryChanged;
final Future<void> 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<String> onCountryChanged;
final Future<void> 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),
);
}

View File

@@ -32,8 +32,10 @@ class BlockPhoneViewModel extends Notifier<BlockPhoneViewState> {
if (device == null) return; if (device == null) return;
final contacts = await _repository.getWhitelist(deviceId: device.id); final contacts = await _repository.getWhitelist(deviceId: device.id);
if (!ref.mounted) return;
state = state.copyWith(contacts: contacts, isLoading: false); state = state.copyWith(contacts: contacts, isLoading: false);
} catch (e) { } catch (e) {
if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: formatErrorMessage(e), errorMessage: formatErrorMessage(e),
@@ -55,6 +57,7 @@ class BlockPhoneViewModel extends Notifier<BlockPhoneViewState> {
deviceId: device.id, deviceId: device.id,
contacts: updatedContacts, contacts: updatedContacts,
); );
if (!ref.mounted) return;
unawaited( unawaited(
_tracking.legacySettingsBlockPhoneContactAdded( _tracking.legacySettingsBlockPhoneContactAdded(
@@ -68,6 +71,7 @@ class BlockPhoneViewModel extends Notifier<BlockPhoneViewState> {
successMessage: I18n.numberAdded, successMessage: I18n.numberAdded,
); );
} catch (e) { } catch (e) {
if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
isSaving: false, isSaving: false,
errorMessage: formatErrorMessage(e), errorMessage: formatErrorMessage(e),
@@ -89,6 +93,7 @@ class BlockPhoneViewModel extends Notifier<BlockPhoneViewState> {
deviceId: device.id, deviceId: device.id,
contacts: updatedContacts, contacts: updatedContacts,
); );
if (!ref.mounted) return;
unawaited( unawaited(
_tracking.legacySettingsBlockPhoneContactRemoved( _tracking.legacySettingsBlockPhoneContactRemoved(
@@ -102,6 +107,7 @@ class BlockPhoneViewModel extends Notifier<BlockPhoneViewState> {
successMessage: I18n.numberRemoved, successMessage: I18n.numberRemoved,
); );
} catch (e) { } catch (e) {
if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
isSaving: false, isSaving: false,
errorMessage: formatErrorMessage(e), errorMessage: formatErrorMessage(e),

View File

@@ -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<NewBlockPhoneContactViewState> {
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<void> openSystemSettings() =>
ref.read(deviceContactPickerProvider).openSystemSettings();
Future<void> 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<bool> 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;
}
}

View File

@@ -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;
}

View File

@@ -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>(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<NewBlockPhoneContactViewState> get copyWith => _$NewBlockPhoneContactViewStateCopyWithImpl<NewBlockPhoneContactViewState>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View File

@@ -1,13 +1,10 @@
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.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 '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';
import '../state/block_phone_view_model.dart';
void showAddContactSheet(BuildContext context) { void showAddContactSheet(BuildContext context) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
@@ -18,220 +15,43 @@ void showAddContactSheet(BuildContext context) {
); );
} }
class _AddContactSheet extends ConsumerStatefulWidget { class _AddContactSheet extends ConsumerWidget {
const _AddContactSheet(); const _AddContactSheet();
@override @override
ConsumerState<_AddContactSheet> createState() => _AddContactSheetState(); Widget build(BuildContext context, WidgetRef ref) {
}
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<void> _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) {
final theme = ref.watch(themePortProvider); final theme = ref.watch(themePortProvider);
final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary); final vm = ref.read(newBlockPhoneContactViewModelProvider.notifier);
final isSaving = ref.watch( final state = ref.watch(newBlockPhoneContactViewModelProvider);
blockPhoneViewModelProvider.select((s) => s.isSaving),
);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Padding( ref.listen(
padding: EdgeInsets.only(bottom: bottomInset), newBlockPhoneContactViewModelProvider.select((s) => s.permissionBlocked),
child: Container( (_, blocked) {
decoration: BoxDecoration( if (blocked == true) {
color: Colors.white, showContactsPermissionDialog(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), context,
), onOpenSettings: vm.openSystemSettings,
child: SafeArea( );
top: false, vm.clearPermissionBlocked();
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)),
],
),
),
),
),
); );
}
InputDecoration _inputDecoration({ return ContactFormSheet(
required String hintText, title: context.translate(I18n.addAllowedNumber),
required Color primaryColor, primaryColor: theme.getColorFor(ThemeCode.legacyPrimary),
}) { nameController: vm.nameController,
return InputDecoration( phoneController: vm.phoneController,
hintText: hintText, isoCode: state.isoCode,
border: OutlineInputBorder( canSave: state.canSave,
borderRadius: BorderRadius.all(Radius.circular(12)), isSubmitting: state.isSubmitting,
borderSide: BorderSide(color: Colors.grey.shade300), phoneError: state.phoneError,
), onCountryChanged: vm.updateCountry,
enabledBorder: OutlineInputBorder( onPickContact: vm.pickContactFromDevice,
borderRadius: BorderRadius.all(Radius.circular(12)), onSubmit: () async {
borderSide: BorderSide(color: Colors.grey.shade300), final success = await vm.submit();
), if (success && context.mounted) Navigator.pop(context);
focusedBorder: OutlineInputBorder( },
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: primaryColor, width: 2),
),
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 14),
); );
} }
} }

View File

@@ -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, NewSosContactViewState>(
NewSosContactViewModel.new,
);
class NewSosContactViewModel extends Notifier<NewSosContactViewState> {
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<void> openSystemSettings() =>
ref.read(deviceContactPickerProvider).openSystemSettings();
Future<void> 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<bool> 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;
}
}

View File

@@ -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;
}

View File

@@ -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>(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<NewSosContactViewState> get copyWith => _$NewSosContactViewStateCopyWithImpl<NewSosContactViewState>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View File

@@ -34,8 +34,10 @@ class SosContactsViewModel extends Notifier<SosContactsViewState> {
final contacts = await _repository.getEmergencyContacts( final contacts = await _repository.getEmergencyContacts(
deviceId: device.id, deviceId: device.id,
); );
if (!ref.mounted) return;
state = state.copyWith(contacts: contacts, isLoading: false); state = state.copyWith(contacts: contacts, isLoading: false);
} catch (e) { } catch (e) {
if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: formatErrorMessage(e), errorMessage: formatErrorMessage(e),
@@ -57,6 +59,7 @@ class SosContactsViewModel extends Notifier<SosContactsViewState> {
deviceId: device.id, deviceId: device.id,
contacts: updatedContacts, contacts: updatedContacts,
); );
if (!ref.mounted) return;
unawaited( unawaited(
_tracking.legacySettingsSosContactAdded( _tracking.legacySettingsSosContactAdded(
@@ -70,6 +73,7 @@ class SosContactsViewModel extends Notifier<SosContactsViewState> {
successMessage: I18n.sosNumberAdded, successMessage: I18n.sosNumberAdded,
); );
} catch (e) { } catch (e) {
if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
isSaving: false, isSaving: false,
errorMessage: formatErrorMessage(e), errorMessage: formatErrorMessage(e),
@@ -91,6 +95,7 @@ class SosContactsViewModel extends Notifier<SosContactsViewState> {
deviceId: device.id, deviceId: device.id,
contacts: updatedContacts, contacts: updatedContacts,
); );
if (!ref.mounted) return;
unawaited( unawaited(
_tracking.legacySettingsSosContactRemoved( _tracking.legacySettingsSosContactRemoved(
@@ -104,6 +109,7 @@ class SosContactsViewModel extends Notifier<SosContactsViewState> {
successMessage: I18n.sosNumberRemoved, successMessage: I18n.sosNumberRemoved,
); );
} catch (e) { } catch (e) {
if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
isSaving: false, isSaving: false,
errorMessage: formatErrorMessage(e), errorMessage: formatErrorMessage(e),

View File

@@ -1,13 +1,10 @@
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.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 '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';
import '../state/sos_contacts_view_model.dart';
void showAddSosContactSheet(BuildContext context) { void showAddSosContactSheet(BuildContext context) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
@@ -18,221 +15,43 @@ void showAddSosContactSheet(BuildContext context) {
); );
} }
class _AddSosContactSheet extends ConsumerStatefulWidget { class _AddSosContactSheet extends ConsumerWidget {
const _AddSosContactSheet(); const _AddSosContactSheet();
@override @override
ConsumerState<_AddSosContactSheet> createState() => Widget build(BuildContext context, WidgetRef ref) {
_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<void> _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) {
final theme = ref.watch(themePortProvider); final theme = ref.watch(themePortProvider);
final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary); final vm = ref.read(newSosContactViewModelProvider.notifier);
final isSaving = ref.watch( final state = ref.watch(newSosContactViewModelProvider);
sosContactsViewModelProvider.select((s) => s.isSaving),
);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Padding( ref.listen(
padding: EdgeInsets.only(bottom: bottomInset), newSosContactViewModelProvider.select((s) => s.permissionBlocked),
child: Container( (_, blocked) {
decoration: BoxDecoration( if (blocked == true) {
color: Colors.white, showContactsPermissionDialog(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), context,
), onOpenSettings: vm.openSystemSettings,
child: SafeArea( );
top: false, vm.clearPermissionBlocked();
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)),
],
),
),
),
),
); );
}
InputDecoration _inputDecoration({ return ContactFormSheet(
required String hintText, title: context.translate(I18n.addSosContact),
required Color primaryColor, primaryColor: theme.getColorFor(ThemeCode.legacyPrimary),
}) { nameController: vm.nameController,
return InputDecoration( phoneController: vm.phoneController,
hintText: hintText, isoCode: state.isoCode,
border: OutlineInputBorder( canSave: state.canSave,
borderRadius: BorderRadius.all(Radius.circular(12)), isSubmitting: state.isSubmitting,
borderSide: BorderSide(color: Colors.grey.shade300), phoneError: state.phoneError,
), onCountryChanged: vm.updateCountry,
enabledBorder: OutlineInputBorder( onPickContact: vm.pickContactFromDevice,
borderRadius: BorderRadius.all(Radius.circular(12)), onSubmit: () async {
borderSide: BorderSide(color: Colors.grey.shade300), final success = await vm.submit();
), if (success && context.mounted) Navigator.pop(context);
focusedBorder: OutlineInputBorder( },
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: primaryColor, width: 2),
),
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 14),
); );
} }
} }