refactor(legacy-settings): migrate sos_contacts CRUD to AsyncNotifier

This commit is contained in:
2026-04-22 02:02:18 +02:00
parent 8c269e8c47
commit 653ea9ab56
15 changed files with 834 additions and 1019 deletions

View File

@@ -0,0 +1,61 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'new_sos_contact_form_provider.g.dart';
class NewSosContactFormState {
const NewSosContactFormState({
this.isoCode = 'ES',
this.canSave = false,
this.permissionBlocked = false,
this.phoneError,
});
final String isoCode;
final bool canSave;
final bool permissionBlocked;
final String? phoneError;
NewSosContactFormState copyWith({
String? isoCode,
bool? canSave,
bool? permissionBlocked,
String? phoneError,
bool clearPhoneError = false,
}) {
return NewSosContactFormState(
isoCode: isoCode ?? this.isoCode,
canSave: canSave ?? this.canSave,
permissionBlocked: permissionBlocked ?? this.permissionBlocked,
phoneError: clearPhoneError ? null : (phoneError ?? this.phoneError),
);
}
}
@riverpod
class NewSosContactForm extends _$NewSosContactForm {
@override
NewSosContactFormState build() => const NewSosContactFormState();
void setCanSave(bool value) {
if (value == state.canSave) return;
state = state.copyWith(canSave: value);
}
void setIsoCode(String code) {
if (code == state.isoCode) return;
state = state.copyWith(isoCode: code, clearPhoneError: true);
}
void setPhoneError(String? error) {
state = state.copyWith(phoneError: error);
}
void clearPhoneError() {
if (state.phoneError == null) return;
state = state.copyWith(clearPhoneError: true);
}
void setPermissionBlocked(bool value) {
state = state.copyWith(permissionBlocked: value);
}
}

View File

@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'new_sos_contact_form_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(NewSosContactForm)
const newSosContactFormProvider = NewSosContactFormProvider._();
final class NewSosContactFormProvider
extends $NotifierProvider<NewSosContactForm, NewSosContactFormState> {
const NewSosContactFormProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'newSosContactFormProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$newSosContactFormHash();
@$internal
@override
NewSosContactForm create() => NewSosContactForm();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NewSosContactFormState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NewSosContactFormState>(value),
);
}
}
String _$newSosContactFormHash() => r'b47b21962c11fc535411154e687eb6815bb4a3fb';
abstract class _$NewSosContactForm extends $Notifier<NewSosContactFormState> {
NewSosContactFormState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<NewSosContactFormState, NewSosContactFormState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<NewSosContactFormState, NewSosContactFormState>,
NewSosContactFormState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,72 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart';
import 'package:settings/src/core/providers/sos_contacts_repository_provider.dart';
import 'package:settings/src/features/sos_contacts/presentation/providers/sos_contacts_provider.dart';
import 'package:sf_tracking/sf_tracking.dart';
part 'sos_contacts_controller.g.dart';
class SosMinimumOneContactException implements Exception {
const SosMinimumOneContactException();
}
@riverpod
class SosContactsController extends _$SosContactsController {
@override
FutureOr<void> build() {}
Future<void> addContact({
required String deviceId,
required String userId,
required List<ContactListContactEntity> current,
required ContactListContactEntity contact,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final updated = [...current, contact];
await ref.read(sosContactsRepositoryProvider).upsertEmergencyContacts(
userId: userId,
deviceId: deviceId,
contacts: updated,
);
ref.invalidate(sosContactsProvider(deviceId));
unawaited(
ref
.read(sfTrackingProvider)
.legacySettingsSosContactAdded(totalCount: updated.length),
);
});
}
Future<void> removeContact({
required String deviceId,
required String userId,
required List<ContactListContactEntity> current,
required int index,
}) async {
if (current.length <= 1) {
state = AsyncError(
const SosMinimumOneContactException(),
StackTrace.current,
);
return;
}
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final updated = [...current]..removeAt(index);
await ref.read(sosContactsRepositoryProvider).upsertEmergencyContacts(
userId: userId,
deviceId: deviceId,
contacts: updated,
);
ref.invalidate(sosContactsProvider(deviceId));
unawaited(
ref
.read(sfTrackingProvider)
.legacySettingsSosContactRemoved(totalCount: updated.length),
);
});
}
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sos_contacts_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SosContactsController)
const sosContactsControllerProvider = SosContactsControllerProvider._();
final class SosContactsControllerProvider
extends $AsyncNotifierProvider<SosContactsController, void> {
const SosContactsControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sosContactsControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sosContactsControllerHash();
@$internal
@override
SosContactsController create() => SosContactsController();
}
String _$sosContactsControllerHash() =>
r'f832432d482c01547771414dff25a358baf72438';
abstract class _$SosContactsController extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
build();
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleValue(ref, null);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart';
import 'package:settings/src/core/providers/sos_contacts_repository_provider.dart';
part 'sos_contacts_provider.g.dart';
@riverpod
Future<List<ContactListContactEntity>> sosContacts(
Ref ref,
String deviceId,
) async {
return ref
.watch(sosContactsRepositoryProvider)
.getEmergencyContacts(deviceId: deviceId);
}

View File

@@ -0,0 +1,91 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sos_contacts_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(sosContacts)
const sosContactsProvider = SosContactsFamily._();
final class SosContactsProvider
extends
$FunctionalProvider<
AsyncValue<List<ContactListContactEntity>>,
List<ContactListContactEntity>,
FutureOr<List<ContactListContactEntity>>
>
with
$FutureModifier<List<ContactListContactEntity>>,
$FutureProvider<List<ContactListContactEntity>> {
const SosContactsProvider._({
required SosContactsFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'sosContactsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sosContactsHash();
@override
String toString() {
return r'sosContactsProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<ContactListContactEntity>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<ContactListContactEntity>> create(Ref ref) {
final argument = this.argument as String;
return sosContacts(ref, argument);
}
@override
bool operator ==(Object other) {
return other is SosContactsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$sosContactsHash() => r'd2cdcdf724f3216b18156a7a1db84d0b0b13f699';
final class SosContactsFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<List<ContactListContactEntity>>,
String
> {
const SosContactsFamily._()
: super(
retry: null,
name: r'sosContactsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SosContactsProvider call(String deviceId) =>
SosContactsProvider._(argument: deviceId, from: this);
@override
String toString() => r'sosContactsProvider';
}

View File

@@ -1,15 +1,16 @@
import 'package:design_system/design_system.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:navigation/navigation.dart';
import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart';
import 'package:settings/src/core/presentation/widgets/contact_list_contact_card.dart';
import 'package:settings/src/features/sos_contacts/presentation/providers/sos_contacts_controller.dart';
import 'package:settings/src/features/sos_contacts/presentation/providers/sos_contacts_provider.dart';
import 'package:settings/src/features/sos_contacts/presentation/widgets/add_sos_contact_sheet.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
import '../../../core/presentation/widgets/contact_list_contact_card.dart';
import 'state/sos_contacts_view_model.dart';
import 'widgets/add_sos_contact_sheet.dart';
class SosContactsScreen extends ConsumerWidget {
final NavigationContract navigationContract;
@@ -17,62 +18,125 @@ class SosContactsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(sosContactsViewModelProvider);
final device = ref.watch(selectedDeviceProvider).value;
final primaryColor = context.sfColors.legacyPrimary;
final maxContacts = device?.capabilities?.contacts
?.maxForType('emergency', fallback: 3) ??
3;
ref.listen(sosContactsViewModelProvider.select((s) => s.errorMessage), (
_,
errorMessage,
) {
if (errorMessage.isNotEmpty) {
showTopSnackbar(
context,
message: errorMessage,
type: MessageType.error,
);
ref.listen(sosContactsControllerProvider, (prev, next) async {
if (prev == null || !prev.isLoading || next.isLoading) return;
if (next.hasError) {
final error = next.error;
if (error is SosMinimumOneContactException) {
await showErrorDialog(context, I18n.sosMinimumOneContact);
} else {
await next.showErrorOn(context);
}
}
});
ref.listen(sosContactsViewModelProvider.select((s) => s.successMessage), (
_,
successMessage,
) {
if (successMessage.isNotEmpty) {
showTopSnackbar(
context,
message: context.translate(successMessage),
type: MessageType.success,
);
ref.read(sosContactsViewModelProvider.notifier).clearSuccess();
}
});
if (device == null) {
return Scaffold(
appBar: _appBar(context, navigationContract, primaryColor),
body: const SizedBox.shrink(),
);
}
return Scaffold(
final contactsAsync = ref.watch(sosContactsProvider(device.id));
return contactsAsync.when(
loading: () => Scaffold(
appBar: _appBar(context, navigationContract, primaryColor),
body: const Center(child: CircularProgressIndicator()),
),
error: (_, __) => Scaffold(
appBar: _appBar(context, navigationContract, primaryColor),
body: Center(child: Text(context.translate(I18n.errorGeneric))),
),
data: (contacts) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: _appBar(
context,
navigationContract,
primaryColor,
onAdd: () async {
if (contacts.length >= maxContacts) {
await showInfoDialog(context, I18n.errorSosContactsMax);
return;
}
if (!context.mounted) return;
showAddSosContactSheet(
context,
deviceId: device.id,
userId: device.userId ?? '',
currentContacts: contacts,
);
},
),
body: SafeArea(
top: false,
child: contacts.isEmpty
? _EmptyState(primaryColor: primaryColor)
: _ContactList(
contacts: contacts,
primaryColor: primaryColor,
onDelete: (index, name) async {
final confirmed = await _confirmDelete(context, name);
if (confirmed != true) return;
if (!context.mounted) return;
await ref
.read(sosContactsControllerProvider.notifier)
.removeContact(
deviceId: device.id,
userId: device.userId ?? '',
current: contacts,
index: index,
);
if (!context.mounted) return;
final state = ref.read(sosContactsControllerProvider);
if (state.hasError) return;
await showSuccessDialog(context, I18n.sosNumberRemoved);
},
),
),
);
},
);
}
PreferredSizeWidget _appBar(
BuildContext context,
NavigationContract nav,
Color primaryColor, {
VoidCallback? onAdd,
}) {
return AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
automaticallyImplyLeading: false,
leading: IconButton(
onPressed: () => navigationContract.goBack(),
icon: Icon(
Icons.adaptive.arrow_back,
color: primaryColor,
size: SizeUtils.getByScreen(small: 32, big: 28),
),
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
automaticallyImplyLeading: false,
leading: IconButton(
onPressed: () => nav.goBack(),
icon: Icon(
Icons.adaptive.arrow_back,
color: primaryColor,
size: SizeUtils.getByScreen(small: 32, big: 28),
),
title: Text(
context.translate(I18n.sosContacts).toUpperCase(),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 20, big: 19),
fontWeight: FontWeight.w500,
letterSpacing: 0,
color: primaryColor,
),
),
title: Text(
context.translate(I18n.sosContacts).toUpperCase(),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 20, big: 19),
fontWeight: FontWeight.w500,
letterSpacing: 0,
color: primaryColor,
),
actions: [
),
actions: [
if (onAdd != null)
Padding(
padding: EdgeInsets.only(
right: SizeUtils.getByScreen(small: 16, big: 14),
@@ -83,17 +147,7 @@ class SosContactsScreen extends ConsumerWidget {
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () {
if (state.contacts.length >= state.maxContacts) {
showTopSnackbar(
context,
message: context.translate(I18n.errorSosContactsMax),
type: MessageType.error,
);
return;
}
showAddSosContactSheet(context);
},
onPressed: onAdd,
icon: Icon(
Icons.add,
color: Colors.white,
@@ -102,16 +156,38 @@ class SosContactsScreen extends ConsumerWidget {
),
),
),
],
);
}
Future<bool?> _confirmDelete(BuildContext context, String name) {
final theme = Theme.of(context);
return showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
icon: Icon(
Icons.warning_amber_rounded,
color: theme.colorScheme.error,
size: 40,
),
title: Text(context.translate(I18n.removeSosContact)),
content: Text(
context.translate(I18n.removeSosContactConfirm, args: {'name': name}),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(context.translate(I18n.cancel)),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
),
child: Text(context.translate(I18n.delete)),
),
],
),
body: SafeArea(
top: false,
child: state.isLoading
? const Center(child: CircularProgressIndicator())
: state.contacts.isEmpty
? _EmptyState(primaryColor: primaryColor)
: _ContactList(),
),
);
}
}
@@ -161,21 +237,24 @@ class _EmptyState extends StatelessWidget {
}
}
class _ContactList extends ConsumerWidget {
const _ContactList();
class _ContactList extends StatelessWidget {
final List<ContactListContactEntity> contacts;
final Color primaryColor;
final void Function(int index, String name) onDelete;
const _ContactList({
required this.contacts,
required this.primaryColor,
required this.onDelete,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final contacts = ref.watch(
sosContactsViewModelProvider.select((s) => s.contacts),
);
final primaryColor = context.sfColors.legacyPrimary;
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Padding(
padding: SizeUtils.getByScreen(
small: EdgeInsets.symmetric(horizontal: 22, vertical: 10),
big: EdgeInsets.symmetric(horizontal: 21, vertical: 8),
small: const EdgeInsets.symmetric(horizontal: 22, vertical: 10),
big: const EdgeInsets.symmetric(horizontal: 21, vertical: 8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -188,8 +267,8 @@ class _ContactList extends ConsumerWidget {
context.translate(I18n.sosDescription),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 14, big: 15),
color: Theme.of(context).colorScheme.onSurface
.withAlpha(178),
color:
Theme.of(context).colorScheme.onSurface.withAlpha(178),
),
),
),
@@ -197,8 +276,7 @@ class _ContactList extends ConsumerWidget {
final contact = contacts[index];
return ContactListContactCard(
contact: contact,
onDelete: () =>
_confirmDelete(context, ref, index, contact.name),
onDelete: () => onDelete(index, contact.name),
);
}),
SizedBox(height: SizeUtils.getByScreen(small: 12, big: 10)),
@@ -218,44 +296,4 @@ class _ContactList extends ConsumerWidget {
),
);
}
void _confirmDelete(
BuildContext context,
WidgetRef ref,
int index,
String name,
) {
final primaryColor = context.sfColors.legacyPrimary;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.translate(I18n.removeSosContact)),
content: Text(
context.translate(I18n.removeSosContactConfirm, args: {'name': name}),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
context.translate(I18n.cancel),
style: TextStyle(color: primaryColor),
),
),
TextButton(
onPressed: () {
ref
.read(sosContactsViewModelProvider.notifier)
.removeContact(index);
Navigator.pop(context);
},
child: Text(
context.translate(I18n.delete),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
);
}
}

View File

@@ -1,119 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,283 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of '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

@@ -1,135 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart';
import 'package:settings/src/core/domain/repositories/sos_contacts_repository.dart';
import 'package:settings/src/core/providers/sos_contacts_repository_provider.dart';
import 'package:settings/src/features/sos_contacts/presentation/state/sos_contacts_view_state.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_tracking/sf_tracking.dart';
final sosContactsViewModelProvider =
NotifierProvider.autoDispose<SosContactsViewModel, SosContactsViewState>(
SosContactsViewModel.new,
);
class SosContactsViewModel extends Notifier<SosContactsViewState> {
late final SosContactsRepository _repository;
late final SfTrackingRepository _tracking;
@override
SosContactsViewState build() {
_repository = ref.read(sosContactsRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
final capabilities = ref.read(selectedDeviceProvider).value?.capabilities;
final maxContacts = capabilities?.contacts?.maxForType('emergency', fallback: 3) ?? 3;
Future.microtask(_load);
return SosContactsViewState(maxContacts: maxContacts);
}
Future<void> _load() async {
try {
final device = ref.read(selectedDeviceProvider).value;
if (device == null) return;
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),
);
}
}
Future<void> addContact(ContactListContactEntity contact) async {
if (state.contacts.length >= state.maxContacts) return;
state = state.copyWith(isSaving: true, errorMessage: '');
try {
final device = ref.read(selectedDeviceProvider).value;
if (device == null) return;
final updatedContacts = [...state.contacts, contact];
await _repository.upsertEmergencyContacts(
userId: device.userId ?? '',
deviceId: device.id,
contacts: updatedContacts,
);
if (!ref.mounted) return;
unawaited(
_tracking.legacySettingsSosContactAdded(
totalCount: updatedContacts.length,
),
);
state = state.copyWith(
contacts: updatedContacts,
isSaving: false,
successMessage: I18n.sosNumberAdded,
);
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(
isSaving: false,
errorMessage: formatErrorMessage(e),
);
}
}
Future<void> removeContact(int index) async {
if (state.contacts.length <= 1) {
state = state.copyWith(
errorMessage: I18n.sosMinimumOneContact,
);
return;
}
state = state.copyWith(isSaving: true, errorMessage: '');
try {
final device = ref.read(selectedDeviceProvider).value;
if (device == null) return;
final updatedContacts = [...state.contacts]..removeAt(index);
await _repository.upsertEmergencyContacts(
userId: device.userId ?? '',
deviceId: device.id,
contacts: updatedContacts,
);
if (!ref.mounted) return;
unawaited(
_tracking.legacySettingsSosContactRemoved(
totalCount: updatedContacts.length,
),
);
state = state.copyWith(
contacts: updatedContacts,
isSaving: false,
successMessage: I18n.sosNumberRemoved,
);
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(
isSaving: false,
errorMessage: formatErrorMessage(e),
);
}
}
void clearSuccess() {
state = state.copyWith(successMessage: '');
}
}

View File

@@ -1,16 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart';
part 'sos_contacts_view_state.freezed.dart';
@freezed
abstract class SosContactsViewState with _$SosContactsViewState {
const factory SosContactsViewState({
@Default([]) List<ContactListContactEntity> contacts,
@Default(true) bool isLoading,
@Default(false) bool isSaving,
@Default(3) int maxContacts,
@Default('') String successMessage,
@Default('') String errorMessage,
}) = _SosContactsViewState;
}

View File

@@ -1,292 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'sos_contacts_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SosContactsViewState {
List<ContactListContactEntity> get contacts; bool get isLoading; bool get isSaving; int get maxContacts; String get successMessage; String get errorMessage;
/// Create a copy of SosContactsViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SosContactsViewStateCopyWith<SosContactsViewState> get copyWith => _$SosContactsViewStateCopyWithImpl<SosContactsViewState>(this as SosContactsViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SosContactsViewState&&const DeepCollectionEquality().equals(other.contacts, contacts)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSaving, isSaving) || other.isSaving == isSaving)&&(identical(other.maxContacts, maxContacts) || other.maxContacts == maxContacts)&&(identical(other.successMessage, successMessage) || other.successMessage == successMessage)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(contacts),isLoading,isSaving,maxContacts,successMessage,errorMessage);
@override
String toString() {
return 'SosContactsViewState(contacts: $contacts, isLoading: $isLoading, isSaving: $isSaving, maxContacts: $maxContacts, successMessage: $successMessage, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class $SosContactsViewStateCopyWith<$Res> {
factory $SosContactsViewStateCopyWith(SosContactsViewState value, $Res Function(SosContactsViewState) _then) = _$SosContactsViewStateCopyWithImpl;
@useResult
$Res call({
List<ContactListContactEntity> contacts, bool isLoading, bool isSaving, int maxContacts, String successMessage, String errorMessage
});
}
/// @nodoc
class _$SosContactsViewStateCopyWithImpl<$Res>
implements $SosContactsViewStateCopyWith<$Res> {
_$SosContactsViewStateCopyWithImpl(this._self, this._then);
final SosContactsViewState _self;
final $Res Function(SosContactsViewState) _then;
/// Create a copy of SosContactsViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? contacts = null,Object? isLoading = null,Object? isSaving = null,Object? maxContacts = null,Object? successMessage = null,Object? errorMessage = null,}) {
return _then(_self.copyWith(
contacts: null == contacts ? _self.contacts : contacts // ignore: cast_nullable_to_non_nullable
as List<ContactListContactEntity>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isSaving: null == isSaving ? _self.isSaving : isSaving // ignore: cast_nullable_to_non_nullable
as bool,maxContacts: null == maxContacts ? _self.maxContacts : maxContacts // ignore: cast_nullable_to_non_nullable
as int,successMessage: null == successMessage ? _self.successMessage : successMessage // ignore: cast_nullable_to_non_nullable
as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [SosContactsViewState].
extension SosContactsViewStatePatterns on SosContactsViewState {
/// 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( _SosContactsViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SosContactsViewState() 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( _SosContactsViewState value) $default,){
final _that = this;
switch (_that) {
case _SosContactsViewState():
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( _SosContactsViewState value)? $default,){
final _that = this;
switch (_that) {
case _SosContactsViewState() 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( List<ContactListContactEntity> contacts, bool isLoading, bool isSaving, int maxContacts, String successMessage, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SosContactsViewState() when $default != null:
return $default(_that.contacts,_that.isLoading,_that.isSaving,_that.maxContacts,_that.successMessage,_that.errorMessage);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<ContactListContactEntity> contacts, bool isLoading, bool isSaving, int maxContacts, String successMessage, String errorMessage) $default,) {final _that = this;
switch (_that) {
case _SosContactsViewState():
return $default(_that.contacts,_that.isLoading,_that.isSaving,_that.maxContacts,_that.successMessage,_that.errorMessage);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<ContactListContactEntity> contacts, bool isLoading, bool isSaving, int maxContacts, String successMessage, String errorMessage)? $default,) {final _that = this;
switch (_that) {
case _SosContactsViewState() when $default != null:
return $default(_that.contacts,_that.isLoading,_that.isSaving,_that.maxContacts,_that.successMessage,_that.errorMessage);case _:
return null;
}
}
}
/// @nodoc
class _SosContactsViewState implements SosContactsViewState {
const _SosContactsViewState({final List<ContactListContactEntity> contacts = const [], this.isLoading = true, this.isSaving = false, this.maxContacts = 3, this.successMessage = '', this.errorMessage = ''}): _contacts = contacts;
final List<ContactListContactEntity> _contacts;
@override@JsonKey() List<ContactListContactEntity> get contacts {
if (_contacts is EqualUnmodifiableListView) return _contacts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_contacts);
}
@override@JsonKey() final bool isLoading;
@override@JsonKey() final bool isSaving;
@override@JsonKey() final int maxContacts;
@override@JsonKey() final String successMessage;
@override@JsonKey() final String errorMessage;
/// Create a copy of SosContactsViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SosContactsViewStateCopyWith<_SosContactsViewState> get copyWith => __$SosContactsViewStateCopyWithImpl<_SosContactsViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SosContactsViewState&&const DeepCollectionEquality().equals(other._contacts, _contacts)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSaving, isSaving) || other.isSaving == isSaving)&&(identical(other.maxContacts, maxContacts) || other.maxContacts == maxContacts)&&(identical(other.successMessage, successMessage) || other.successMessage == successMessage)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_contacts),isLoading,isSaving,maxContacts,successMessage,errorMessage);
@override
String toString() {
return 'SosContactsViewState(contacts: $contacts, isLoading: $isLoading, isSaving: $isSaving, maxContacts: $maxContacts, successMessage: $successMessage, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class _$SosContactsViewStateCopyWith<$Res> implements $SosContactsViewStateCopyWith<$Res> {
factory _$SosContactsViewStateCopyWith(_SosContactsViewState value, $Res Function(_SosContactsViewState) _then) = __$SosContactsViewStateCopyWithImpl;
@override @useResult
$Res call({
List<ContactListContactEntity> contacts, bool isLoading, bool isSaving, int maxContacts, String successMessage, String errorMessage
});
}
/// @nodoc
class __$SosContactsViewStateCopyWithImpl<$Res>
implements _$SosContactsViewStateCopyWith<$Res> {
__$SosContactsViewStateCopyWithImpl(this._self, this._then);
final _SosContactsViewState _self;
final $Res Function(_SosContactsViewState) _then;
/// Create a copy of SosContactsViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? contacts = null,Object? isLoading = null,Object? isSaving = null,Object? maxContacts = null,Object? successMessage = null,Object? errorMessage = null,}) {
return _then(_SosContactsViewState(
contacts: null == contacts ? _self._contacts : contacts // ignore: cast_nullable_to_non_nullable
as List<ContactListContactEntity>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isSaving: null == isSaving ? _self.isSaving : isSaving // ignore: cast_nullable_to_non_nullable
as bool,maxContacts: null == maxContacts ? _self.maxContacts : maxContacts // ignore: cast_nullable_to_non_nullable
as int,successMessage: null == successMessage ? _self.successMessage : successMessage // ignore: cast_nullable_to_non_nullable
as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -1,57 +1,178 @@
import 'package:design_system/design_system.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:legacy_theme/legacy_theme.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 'package:settings/src/features/sos_contacts/presentation/providers/new_sos_contact_form_provider.dart';
import 'package:settings/src/features/sos_contacts/presentation/providers/sos_contacts_controller.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
void showAddSosContactSheet(BuildContext context) {
void showAddSosContactSheet(
BuildContext context, {
required String deviceId,
required String userId,
required List<ContactListContactEntity> currentContacts,
}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const _AddSosContactSheet(),
builder: (_) => _AddSosContactSheet(
deviceId: deviceId,
userId: userId,
currentContacts: currentContacts,
),
);
}
class _AddSosContactSheet extends ConsumerWidget {
const _AddSosContactSheet();
class _AddSosContactSheet extends ConsumerStatefulWidget {
final String deviceId;
final String userId;
final List<ContactListContactEntity> currentContacts;
const _AddSosContactSheet({
required this.deviceId,
required this.userId,
required this.currentContacts,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(newSosContactViewModelProvider.notifier);
final state = ref.watch(newSosContactViewModelProvider);
ConsumerState<_AddSosContactSheet> createState() =>
_AddSosContactSheetState();
}
ref.listen(
newSosContactViewModelProvider.select((s) => s.permissionBlocked),
(_, blocked) {
if (blocked == true) {
showContactsPermissionDialog(
context,
onOpenSettings: vm.openSystemSettings,
);
vm.clearPermissionBlocked();
}
},
class _AddSosContactSheetState extends ConsumerState<_AddSosContactSheet> {
late final TextEditingController _nameController;
late final TextEditingController _phoneController;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_phoneController = TextEditingController();
_nameController.addListener(_refreshCanSave);
_phoneController.addListener(_onPhoneChanged);
}
@override
void dispose() {
_nameController.removeListener(_refreshCanSave);
_phoneController.removeListener(_onPhoneChanged);
_nameController.dispose();
_phoneController.dispose();
super.dispose();
}
void _refreshCanSave() {
final canSave = _nameController.text.trim().isNotEmpty &&
_phoneController.text.trim().isNotEmpty;
ref.read(newSosContactFormProvider.notifier).setCanSave(canSave);
}
void _onPhoneChanged() {
_refreshCanSave();
ref.read(newSosContactFormProvider.notifier).clearPhoneError();
}
Future<void> _pickContactFromDevice() async {
final formNotifier = ref.read(newSosContactFormProvider.notifier);
final isoCode = ref.read(newSosContactFormProvider).isoCode;
final response = await ref
.read(deviceContactPickerProvider)
.pick(hintIsoCode: isoCode);
if (response.outcome ==
DeviceContactPickOutcome.permissionPermanentlyDenied) {
formNotifier.setPermissionBlocked(true);
return;
}
final data = response.data;
if (data == null) return;
final parsed = data.parsedPhone;
if (parsed != null) {
formNotifier.setIsoCode(parsed.isoCode);
_phoneController.text = parsed.nationalNumber;
} else {
_phoneController.text = data.rawNumber;
formNotifier.clearPhoneError();
}
if (_nameController.text.trim().isEmpty) {
_nameController.text = data.displayName;
}
}
Future<void> _submit() async {
final formNotifier = ref.read(newSosContactFormProvider.notifier);
final formState = ref.read(newSosContactFormProvider);
if (!formState.canSave) return;
final parsed = SfPhoneNumber.tryParse(
_phoneController.text,
defaultIsoCode: formState.isoCode,
);
if (parsed == null) {
formNotifier.setPhoneError(I18n.errorMessagePhoneIsInvalid);
return;
}
await ref.read(sosContactsControllerProvider.notifier).addContact(
deviceId: widget.deviceId,
userId: widget.userId,
current: widget.currentContacts,
contact: ContactListContactEntity(
name: _nameController.text.trim(),
phone: parsed.e164,
),
);
if (!context.mounted) return;
final state = ref.read(sosContactsControllerProvider);
if (state.hasError) return;
Navigator.of(context).pop();
await showSuccessDialog(context, I18n.sosNumberAdded);
}
@override
Widget build(BuildContext context) {
ref.listen(newSosContactFormProvider.select((s) => s.permissionBlocked), (
_,
blocked,
) {
if (blocked == true) {
showContactsPermissionDialog(
context,
onOpenSettings: ref
.read(deviceContactPickerProvider)
.openSystemSettings,
);
ref.read(newSosContactFormProvider.notifier).setPermissionBlocked(false);
}
});
final formState = ref.watch(newSosContactFormProvider);
final isSubmitting = ref.watch(
sosContactsControllerProvider.select((s) => s.isLoading),
);
return ContactFormSheet(
title: context.translate(I18n.addSosContact),
primaryColor: context.sfColors.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);
},
nameController: _nameController,
phoneController: _phoneController,
isoCode: formState.isoCode,
canSave: formState.canSave,
isSubmitting: isSubmitting,
phoneError: formState.phoneError,
onCountryChanged: ref
.read(newSosContactFormProvider.notifier)
.setIsoCode,
onPickContact: _pickContactFromDevice,
onSubmit: _submit,
);
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart';
import 'package:settings/src/core/domain/repositories/sos_contacts_repository.dart';
import 'package:settings/src/core/providers/sos_contacts_repository_provider.dart';
import 'package:settings/src/features/sos_contacts/presentation/providers/sos_contacts_controller.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/testing.dart';
import 'package:sf_tracking/sf_tracking.dart';
class MockSosContactsRepository extends Mock implements SosContactsRepository {}
const _a = ContactListContactEntity(name: 'Alice', phone: '+34600000001');
const _b = ContactListContactEntity(name: 'Bob', phone: '+34600000002');
void main() {
setUpAll(() {
registerFallbackValue(const <ContactListContactEntity>[]);
});
ProviderContainer buildContainer(SosContactsRepository repo) {
return makeContainer(
overrides: [
sosContactsRepositoryProvider.overrideWithValue(repo),
sfTrackingProvider.overrideWithValue(
SfTrackingRepository(clients: const []),
),
],
);
}
group('SosContactsController.addContact', () {
test('appends contact and persists via repository', () async {
final repo = MockSosContactsRepository();
when(
() => repo.upsertEmergencyContacts(
userId: any(named: 'userId'),
deviceId: any(named: 'deviceId'),
contacts: any(named: 'contacts'),
),
).thenAnswer((_) async => const [_a, _b]);
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(sosContactsControllerProvider.notifier).addContact(
deviceId: 'd',
userId: 'u',
current: const [_a],
contact: _b,
);
expect(
container.read(sosContactsControllerProvider),
isA<AsyncData<void>>(),
);
verify(
() => repo.upsertEmergencyContacts(
userId: 'u',
deviceId: 'd',
contacts: [_a, _b],
),
).called(1);
});
test('exposes AsyncError when repository fails', () async {
final repo = MockSosContactsRepository();
when(
() => repo.upsertEmergencyContacts(
userId: any(named: 'userId'),
deviceId: any(named: 'deviceId'),
contacts: any(named: 'contacts'),
),
).thenThrow(const ApiException(message: 'boom', isNetworkError: true));
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(sosContactsControllerProvider.notifier).addContact(
deviceId: 'd',
userId: 'u',
current: const [],
contact: _a,
);
expect(
container.read(sosContactsControllerProvider),
isA<AsyncError<void>>(),
);
});
});
group('SosContactsController.removeContact', () {
test('blocks removal when only one contact remains', () async {
final repo = MockSosContactsRepository();
final container = buildContainer(repo);
addTearDown(container.dispose);
await container
.read(sosContactsControllerProvider.notifier)
.removeContact(
deviceId: 'd',
userId: 'u',
current: const [_a],
index: 0,
);
final state = container.read(sosContactsControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, isA<SosMinimumOneContactException>());
verifyNever(
() => repo.upsertEmergencyContacts(
userId: any(named: 'userId'),
deviceId: any(named: 'deviceId'),
contacts: any(named: 'contacts'),
),
);
});
test('removes contact and persists via repository', () async {
final repo = MockSosContactsRepository();
when(
() => repo.upsertEmergencyContacts(
userId: any(named: 'userId'),
deviceId: any(named: 'deviceId'),
contacts: any(named: 'contacts'),
),
).thenAnswer((_) async => const [_b]);
final container = buildContainer(repo);
addTearDown(container.dispose);
await container
.read(sosContactsControllerProvider.notifier)
.removeContact(
deviceId: 'd',
userId: 'u',
current: const [_a, _b],
index: 0,
);
expect(
container.read(sosContactsControllerProvider),
isA<AsyncData<void>>(),
);
verify(
() => repo.upsertEmergencyContacts(
userId: 'u',
deviceId: 'd',
contacts: [_b],
),
).called(1);
});
});
}