refactor(device_management): migrate contacts to Riverpod

This commit is contained in:
2026-04-22 22:14:06 +02:00
parent 35a943c066
commit 94e2fcbf7d
32 changed files with 1278 additions and 1563 deletions

View File

@@ -1,17 +1,19 @@
import 'package:design_system/design_system.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_controller.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_editing_mode_provider.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_provider.dart';
import 'package:device_management/src/features/contacts/presentation/widgets/contact_card.dart';
import 'package:device_management/src/features/contacts/presentation/widgets/new_contact_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'package:navigation/navigation.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
import 'state/contacts_view_model.dart';
import 'widgets/contact_card.dart';
import 'widgets/new_contact_dialog.dart';
class ContactsScreen extends ConsumerWidget {
final NavigationContract navigationContract;
@@ -19,61 +21,106 @@ class ContactsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(contactsViewModelProvider.notifier);
final state = ref.watch(contactsViewModelProvider);
final primaryColor = context.sfColors.legacyPrimary;
final isEditing = ref.watch(contactsEditingModeProvider);
final userAsync = ref.watch(userInfoProvider);
final device = ref.watch(selectedDeviceProvider).value;
final maxContacts =
device?.capabilities?.contacts?.maxForType('white') ?? 10;
ref.listen(contactsViewModelProvider.select((s) => s.error), (prev, next) {
if (next == null) return;
showTopSnackbar(
context,
message: context.translate(next.i18nKey),
type: MessageType.error,
);
vm.clearError();
ref.listen(contactsControllerProvider, (prev, next) async {
if (prev == null || !prev.isLoading || next.isLoading) return;
if (next.hasError) {
final error = next.error;
if (error is ContactsMinReachedException) {
await showErrorDialog(context, I18n.errorContactsMin);
} else if (error is ContactsMaxReachedException) {
await showErrorDialog(context, I18n.errorContactsMax);
} else {
await next.showErrorOn(context);
}
return;
}
final action =
ref.read(contactsControllerProvider.notifier).lastAction;
final key = switch (action) {
ContactsAction.create => I18n.contactAdded,
ContactsAction.update => I18n.contactUpdated,
ContactsAction.delete => I18n.contactDeleted,
null => I18n.deviceUpdatedSuccess,
};
await showSuccessDialog(context, key);
});
return LegacyPageLayout(
title: context.translate(I18n.contactsAgendaTitle),
showEdit: true,
onEditChange: vm.toggleIsEditing,
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: state.contacts.isEmpty
? const _EmptyState()
: ListView.separated(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 22, big: 21),
vertical: SizeUtils.getByScreen(small: 10, big: 8),
),
itemBuilder: (_, index) => ContactCard(
contact: state.contacts[index],
isEditing: state.isEditing,
),
separatorBuilder: (_, __) =>
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
itemCount: state.contacts.length,
onEditChange: ref.read(contactsEditingModeProvider.notifier).toggle,
body: userAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: Text(context.translate(I18n.errorGeneric)),
),
data: (user) {
final contactsAsync = ref.watch(contactsProvider(user.id));
return contactsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: Text(context.translate(I18n.errorGeneric)),
),
data: (contacts) {
if (contacts.isEmpty) return const _EmptyState();
return ListView.separated(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 22, big: 21),
vertical: SizeUtils.getByScreen(small: 10, big: 8),
),
itemBuilder: (_, index) => ContactCard(
contact: contacts[index],
isEditing: isEditing,
onDelete: () => _confirmDelete(
context,
ref,
userId: user.id,
contactId: contacts[index].id,
currentCount: contacts.length,
),
),
separatorBuilder: (_, __) => SizedBox(
height: SizeUtils.getByScreen(small: 18, big: 17),
),
itemCount: contacts.length,
);
},
);
},
),
footer: Material(
color: context.sfColors.legacyPrimary,
color: primaryColor,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () async {
if (!await guardDeviceCommand(context, ref)) return;
if (!context.mounted) return;
if (state.contacts.length >= state.maxContacts) {
showTopSnackbar(
context,
message: context.translate(I18n.errorContactsMax),
);
final user = userAsync.value;
if (user == null) return;
final contacts =
ref.read(contactsProvider(user.id)).value ?? const [];
if (contacts.length >= maxContacts) {
await showErrorDialog(context, I18n.errorContactsMax);
return;
}
showDialog(
if (!context.mounted) return;
showDialog<void>(
context: context,
builder: (_) => const Dialog(
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
child: NewContactDialog(),
child: NewContactDialog(
userId: user.id,
currentCount: contacts.length,
maxContacts: maxContacts,
),
),
);
},
@@ -90,10 +137,43 @@ class ContactsScreen extends ConsumerWidget {
),
);
}
Future<void> _confirmDelete(
BuildContext context,
WidgetRef ref, {
required String userId,
required String contactId,
required int currentCount,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.translate(I18n.deleteContactMessage)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.translate(I18n.cancel)),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.translate(I18n.delete)),
),
],
),
);
if (confirmed != true) return;
ref.read(contactsControllerProvider.notifier).deleteContact(
userId: userId,
contactId: contactId,
currentCount: currentCount,
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
@@ -105,8 +185,8 @@ class _EmptyState extends StatelessWidget {
Icon(
SFIcons.contactsCircle,
size: SizeUtils.getByScreen(small: 64, big: 60),
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.3),
color:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 14)),
Text(
@@ -122,7 +202,9 @@ class _EmptyState extends StatelessWidget {
context.translate(I18n.contactsEmptyHint),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 14, big: 13),
color: Theme.of(context).colorScheme.onSurface
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.5),
),
),

View File

@@ -1,15 +1,18 @@
import 'package:design_system/design_system.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:device_management/src/features/contacts/domain/entities/contact_entity.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_controller.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_provider.dart';
import 'package:device_management/src/features/contacts/presentation/providers/edit_contact_form_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get_it/get_it.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:navigation/navigation.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
import 'state/edit_contact_view_model.dart';
class EditContactScreen extends ConsumerWidget {
final String contactId;
@@ -17,68 +20,168 @@ class EditContactScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = editContactViewModelProvider(contactId);
final vm = ref.read(provider.notifier);
final state = ref.watch(provider);
ref.listen(provider.select((s) => s.error), (_, next) {
if (next == null) return;
showTopSnackbar(
context,
message: context.translate(next.i18nKey),
type: MessageType.error,
);
vm.clearError();
});
final contact = state.contact;
if (contact == null) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: Center(child: Text(context.translate(I18n.errorGeneric))),
);
}
final userAsync = ref.watch(userInfoProvider);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: Column(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 22, big: 21),
vertical: SizeUtils.getByScreen(small: 10, big: 8),
child: userAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: Text(context.translate(I18n.errorGeneric)),
),
data: (user) {
final contactsAsync = ref.watch(contactsProvider(user.id));
return contactsAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: Text(context.translate(I18n.errorGeneric)),
),
child: Stack(
children: [
IconButton(
onPressed: () => GetIt.I<NavigationContract>().goBack(),
icon: const Icon(Icons.arrow_back),
),
Center(
child: Text(
context.translate(I18n.editContact),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 28, big: 27),
),
),
),
],
data: (contacts) {
final contact =
contacts.where((c) => c.id == contactId).firstOrNull;
if (contact == null) {
return Center(
child: Text(context.translate(I18n.errorGeneric)),
);
}
return _EditContactForm(userId: user.id, contact: contact);
},
);
},
),
),
);
}
}
class _EditContactForm extends ConsumerStatefulWidget {
final String userId;
final ContactEntity contact;
const _EditContactForm({required this.userId, required this.contact});
@override
ConsumerState<_EditContactForm> createState() => _EditContactFormState();
}
class _EditContactFormState extends ConsumerState<_EditContactForm> {
late final TextEditingController _nameController;
late final TextEditingController _phoneController;
late final String _initialIsoCode;
@override
void initState() {
super.initState();
final parsed = widget.contact.phone;
_nameController = TextEditingController(text: widget.contact.name);
_phoneController = TextEditingController(
text: parsed?.nationalNumber ?? widget.contact.rawPhone,
);
_initialIsoCode = parsed?.isoCode ?? SfPhoneNumber.defaultIsoCode;
_nameController.addListener(_clearError);
_phoneController.addListener(_clearError);
ref.listenManual(contactsControllerProvider, (prev, next) async {
if (prev == null || !prev.isLoading || next.isLoading) return;
final action =
ref.read(contactsControllerProvider.notifier).lastAction;
if (action != ContactsAction.update) return;
if (next.hasError) {
await next.showErrorOn(context);
return;
}
if (mounted) GetIt.I<NavigationContract>().goBack();
});
}
@override
void dispose() {
_nameController.removeListener(_clearError);
_phoneController.removeListener(_clearError);
_nameController.dispose();
_phoneController.dispose();
super.dispose();
}
void _clearError() {
ref.read(editContactFormProvider(_initialIsoCode).notifier).clearError();
}
void _submit() {
final formNotifier =
ref.read(editContactFormProvider(_initialIsoCode).notifier);
final name = _nameController.text.trim();
if (name.isEmpty) {
formNotifier.setLocalError(I18n.errorFirstNameRequired);
return;
}
final isoCode =
ref.read(editContactFormProvider(_initialIsoCode)).isoCode;
final parsed = SfPhoneNumber.tryParse(
_phoneController.text,
defaultIsoCode: isoCode,
);
if (parsed == null) {
formNotifier.setLocalError(I18n.errorMessagePhoneIsInvalid);
return;
}
formNotifier.clearError();
ref.read(contactsControllerProvider.notifier).updateContact(
userId: widget.userId,
contactId: widget.contact.id,
name: name,
phoneE164: parsed.e164,
);
}
@override
Widget build(BuildContext context) {
final primaryColor = context.sfColors.legacyPrimary;
final formState = ref.watch(editContactFormProvider(_initialIsoCode));
final isSubmitting =
ref.watch(contactsControllerProvider.select((s) => s.isLoading));
return Column(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 22, big: 21),
vertical: SizeUtils.getByScreen(small: 10, big: 8),
),
child: Stack(
children: [
IconButton(
onPressed: () => GetIt.I<NavigationContract>().goBack(),
icon: const Icon(Icons.arrow_back),
),
),
SizedBox(height: SizeUtils.getByScreen(small: 20, big: 18)),
Expanded(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 48, big: 47),
vertical: SizeUtils.getByScreen(small: 10, big: 8),
Center(
child: Text(
context.translate(I18n.editContact),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 28, big: 27),
),
),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
),
],
),
),
SizedBox(height: SizeUtils.getByScreen(small: 20, big: 18)),
Expanded(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 48, big: 47),
vertical: SizeUtils.getByScreen(small: 10, big: 8),
),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
@@ -90,7 +193,7 @@ class EditContactScreen extends ConsumerWidget {
height: SizeUtils.getByScreen(small: 24, big: 22),
),
CustomTextField(
controller: vm.nameController,
controller: _nameController,
label: context.translate(I18n.name),
),
SizedBox(
@@ -102,10 +205,16 @@ class EditContactScreen extends ConsumerWidget {
headerText: context.translate(
I18n.selectYourCountry,
),
initialSelection: state.isoCode,
initialSelection: formState.isoCode,
onChanged: (country) {
final code = country.code;
if (code != null) vm.updateCountry(code);
if (code != null) {
ref
.read(editContactFormProvider(
_initialIsoCode)
.notifier)
.setIsoCode(code);
}
},
width: 80,
),
@@ -118,37 +227,38 @@ class EditContactScreen extends ConsumerWidget {
),
Expanded(
child: CustomTextField(
controller: vm.phoneController,
controller: _phoneController,
keyboardType: TextInputType.number,
),
),
],
),
],
),
),
if (formState.localError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
context.translate(formState.localError!),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
),
],
),
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 14)),
PrimaryButton(
onPressed: state.isSubmitting
? null
: () async {
final success = await vm.submit();
if (success && context.mounted) {
GetIt.I<NavigationContract>().goBack();
}
},
text: context.translate(I18n.save),
color: context.sfColors.legacyPrimary,
),
],
),
),
),
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 14)),
PrimaryButton(
onPressed: isSubmitting ? null : _submit,
text: context.translate(I18n.save),
color: primaryColor,
),
],
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,115 @@
import 'dart:async';
import 'package:device_management/src/core/data/models/create_contact_request_dto.dart';
import 'package:device_management/src/core/data/models/update_contact_request_dto.dart';
import 'package:device_management/src/core/providers/contacts_repository_provider.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:uuid/uuid.dart';
part 'contacts_controller.g.dart';
class ContactsMinReachedException implements Exception {
const ContactsMinReachedException();
}
class ContactsMaxReachedException implements Exception {
const ContactsMaxReachedException();
}
enum ContactsAction { create, update, delete }
@riverpod
class ContactsController extends _$ContactsController {
static const _uuid = Uuid();
ContactsAction? _lastAction;
ContactsAction? get lastAction => _lastAction;
@override
FutureOr<void> build() {}
Future<void> createContact({
required String userId,
required String name,
required String phoneE164,
required int currentCount,
required int maxCount,
}) async {
_lastAction = ContactsAction.create;
state = const AsyncLoading();
if (currentCount >= maxCount) {
state = AsyncError(
const ContactsMaxReachedException(),
StackTrace.current,
);
return;
}
state = await AsyncValue.guard(() async {
await ref.read(contactsRepositoryProvider).createContact(
request: CreateContactRequestDto(
id: _uuid.v4(),
name: name,
phone: phoneE164,
userId: userId,
),
);
ref.invalidate(contactsProvider(userId));
unawaited(
ref
.read(sfTrackingProvider)
.legacyContactsAdded(totalCount: currentCount + 1),
);
});
}
Future<void> updateContact({
required String userId,
required String contactId,
required String name,
required String phoneE164,
}) async {
_lastAction = ContactsAction.update;
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(contactsRepositoryProvider).updateContact(
request: UpdateContactRequestDto(
id: contactId,
name: name,
phone: phoneE164,
),
);
ref.invalidate(contactsProvider(userId));
unawaited(ref.read(sfTrackingProvider).legacyContactsEdited());
});
}
Future<void> deleteContact({
required String userId,
required String contactId,
required int currentCount,
}) async {
_lastAction = ContactsAction.delete;
state = const AsyncLoading();
if (currentCount <= 1) {
state = AsyncError(
const ContactsMinReachedException(),
StackTrace.current,
);
return;
}
state = await AsyncValue.guard(() async {
await ref
.read(contactsRepositoryProvider)
.deleteContact(contactId: contactId);
ref.invalidate(contactsProvider(userId));
unawaited(
ref
.read(sfTrackingProvider)
.legacyContactsDeleted(totalCount: currentCount - 1),
);
});
}
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'contacts_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ContactsController)
const contactsControllerProvider = ContactsControllerProvider._();
final class ContactsControllerProvider
extends $AsyncNotifierProvider<ContactsController, void> {
const ContactsControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'contactsControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$contactsControllerHash();
@$internal
@override
ContactsController create() => ContactsController();
}
String _$contactsControllerHash() =>
r'a304d9260a03626ce75b0cf5e62bf2663ee9feec';
abstract class _$ContactsController 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,16 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'contacts_editing_mode_provider.g.dart';
@riverpod
class ContactsEditingMode extends _$ContactsEditingMode {
@override
bool build() => false;
void toggle() => state = !state;
void set(bool value) {
if (value == state) return;
state = value;
}
}

View File

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

View File

@@ -0,0 +1,26 @@
import 'dart:async';
import 'package:device_management/src/core/providers/contacts_repository_provider.dart';
import 'package:device_management/src/features/contacts/domain/entities/contact_entity.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_shared/sf_shared.dart';
part 'contacts_provider.g.dart';
@riverpod
Future<List<ContactEntity>> contacts(Ref ref, String userId) async {
final repo = ref.read(contactsRepositoryProvider);
final contacts = await repo.getContacts(userId: userId);
final device = ref.read(selectedDeviceProvider).value;
if (device != null) {
unawaited(
repo.syncContactsToDevice(
userId: userId,
deviceId: device.id,
contacts: contacts,
),
);
}
return contacts;
}

View File

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

View File

@@ -0,0 +1,46 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_shared/sf_shared.dart';
part 'edit_contact_form_provider.g.dart';
class EditContactFormState {
const EditContactFormState({required this.isoCode, this.localError});
final String isoCode;
final String? localError;
EditContactFormState copyWith({
String? isoCode,
String? localError,
bool clearError = false,
}) {
return EditContactFormState(
isoCode: isoCode ?? this.isoCode,
localError: clearError ? null : (localError ?? this.localError),
);
}
}
@riverpod
class EditContactForm extends _$EditContactForm {
@override
EditContactFormState build(String initialIsoCode) => EditContactFormState(
isoCode: initialIsoCode.isNotEmpty
? initialIsoCode
: SfPhoneNumber.defaultIsoCode,
);
void setIsoCode(String code) {
if (code == state.isoCode) return;
state = state.copyWith(isoCode: code);
}
void setLocalError(String key) {
state = state.copyWith(localError: key);
}
void clearError() {
if (state.localError == null) return;
state = state.copyWith(clearError: true);
}
}

View File

@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'edit_contact_form_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(EditContactForm)
const editContactFormProvider = EditContactFormFamily._();
final class EditContactFormProvider
extends $NotifierProvider<EditContactForm, EditContactFormState> {
const EditContactFormProvider._({
required EditContactFormFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'editContactFormProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$editContactFormHash();
@override
String toString() {
return r'editContactFormProvider'
''
'($argument)';
}
@$internal
@override
EditContactForm create() => EditContactForm();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EditContactFormState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EditContactFormState>(value),
);
}
@override
bool operator ==(Object other) {
return other is EditContactFormProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$editContactFormHash() => r'8a6ee15611761bd53cf1d335bd16ed5443d7400c';
final class EditContactFormFamily extends $Family
with
$ClassFamilyOverride<
EditContactForm,
EditContactFormState,
EditContactFormState,
EditContactFormState,
String
> {
const EditContactFormFamily._()
: super(
retry: null,
name: r'editContactFormProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
EditContactFormProvider call(String initialIsoCode) =>
EditContactFormProvider._(argument: initialIsoCode, from: this);
@override
String toString() => r'editContactFormProvider';
}
abstract class _$EditContactForm extends $Notifier<EditContactFormState> {
late final _$args = ref.$arg as String;
String get initialIsoCode => _$args;
EditContactFormState build(String initialIsoCode);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args);
final ref = this.ref as $Ref<EditContactFormState, EditContactFormState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<EditContactFormState, EditContactFormState>,
EditContactFormState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'new_contact_form_provider.g.dart';
class NewContactFormState {
const NewContactFormState({
this.isoCode = 'ES',
this.localError,
});
final String isoCode;
final String? localError;
NewContactFormState copyWith({
String? isoCode,
String? localError,
bool clearError = false,
}) {
return NewContactFormState(
isoCode: isoCode ?? this.isoCode,
localError: clearError ? null : (localError ?? this.localError),
);
}
}
@riverpod
class NewContactForm extends _$NewContactForm {
@override
NewContactFormState build() => const NewContactFormState();
void setIsoCode(String code) {
if (code == state.isoCode) return;
state = state.copyWith(isoCode: code);
}
void setLocalError(String key) {
state = state.copyWith(localError: key);
}
void clearError() {
if (state.localError == null) return;
state = state.copyWith(clearError: true);
}
}

View File

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

View File

@@ -1,126 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import '../../../../core/domain/repositories/contacts_repository.dart';
import '../../../../core/providers/contacts_repository_provider.dart';
import '../../domain/entities/contact_entity.dart';
import '../../domain/entities/contact_error.dart';
import 'contacts_view_state.dart';
final contactsViewModelProvider =
NotifierProvider.autoDispose<ContactsViewModel, ContactsViewState>(
ContactsViewModel.new,
);
class ContactsViewModel extends Notifier<ContactsViewState> {
late final ContactsRepository _contactsRepository;
late final SfTrackingRepository _tracking;
@override
ContactsViewState build() {
_contactsRepository = ref.read(contactsRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
final capabilities = ref.read(selectedDeviceProvider).value?.capabilities;
final maxContacts = capabilities?.contacts?.maxForType('white') ?? 10;
_load();
return ContactsViewState(maxContacts: maxContacts);
}
void toggleIsEditing() {
state = state.copyWith(isEditing: !state.isEditing);
}
void clearError() {
if (state.error != null) state = state.copyWith(error: null);
}
Future<void> refresh() async {
try {
final user = await ref.read(userInfoProvider.future);
if (!ref.mounted) return;
await _reload(user.id);
} catch (_) {
if (!ref.mounted) return;
state = state.copyWith(error: ContactError.network);
}
}
Future<bool> deleteContact(ContactEntity contact) async {
if (state.isSubmitting) return false;
if (state.contacts.length <= 1) {
state = state.copyWith(error: ContactError.minReached);
return false;
}
state = state.copyWith(isSubmitting: true, error: null);
try {
await _contactsRepository.deleteContact(contactId: contact.id);
if (!ref.mounted) return false;
unawaited(
_tracking.legacyContactsDeleted(
totalCount: (state.contacts.length - 1).clamp(0, 999),
),
);
final user = await ref.read(userInfoProvider.future);
if (!ref.mounted) return false;
await _reload(user.id);
return true;
} catch (_) {
if (!ref.mounted) return false;
state = state.copyWith(isSubmitting: false, error: ContactError.network);
return false;
}
}
Future<void> _load() async {
try {
final user = await ref.read(userInfoProvider.future);
if (!ref.mounted) return;
await _reload(user.id);
} catch (_) {
if (!ref.mounted) return;
state = state.copyWith(isLoading: false, error: ContactError.network);
}
}
Future<void> _reload(String userId) async {
final contacts = await _contactsRepository.getContacts(userId: userId);
if (!ref.mounted) return;
state = state.copyWith(
contacts: contacts,
isLoading: false,
isSubmitting: false,
);
unawaited(_syncToDevice(userId, contacts));
}
Future<void> _syncToDevice(
String userId,
List<ContactEntity> contacts,
) async {
final device = ref.read(selectedDeviceProvider).value;
if (device == null) return;
try {
await _contactsRepository.syncContactsToDevice(
userId: userId,
deviceId: device.id,
contacts: contacts,
);
} catch (_) {
// Non-blocking: CRUD already committed server-side.
}
}
}

View File

@@ -1,18 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/contact_entity.dart';
import '../../domain/entities/contact_error.dart';
part 'contacts_view_state.freezed.dart';
@freezed
abstract class ContactsViewState with _$ContactsViewState {
const factory ContactsViewState({
@Default([]) List<ContactEntity> contacts,
@Default(true) bool isLoading,
@Default(false) bool isSubmitting,
@Default(false) bool isEditing,
@Default(10) int maxContacts,
ContactError? error,
}) = _ContactsViewState;
}

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 'contacts_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ContactsViewState {
List<ContactEntity> get contacts; bool get isLoading; bool get isSubmitting; bool get isEditing; int get maxContacts; ContactError? get error;
/// Create a copy of ContactsViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ContactsViewStateCopyWith<ContactsViewState> get copyWith => _$ContactsViewStateCopyWithImpl<ContactsViewState>(this as ContactsViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ContactsViewState&&const DeepCollectionEquality().equals(other.contacts, contacts)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.isEditing, isEditing) || other.isEditing == isEditing)&&(identical(other.maxContacts, maxContacts) || other.maxContacts == maxContacts)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(contacts),isLoading,isSubmitting,isEditing,maxContacts,error);
@override
String toString() {
return 'ContactsViewState(contacts: $contacts, isLoading: $isLoading, isSubmitting: $isSubmitting, isEditing: $isEditing, maxContacts: $maxContacts, error: $error)';
}
}
/// @nodoc
abstract mixin class $ContactsViewStateCopyWith<$Res> {
factory $ContactsViewStateCopyWith(ContactsViewState value, $Res Function(ContactsViewState) _then) = _$ContactsViewStateCopyWithImpl;
@useResult
$Res call({
List<ContactEntity> contacts, bool isLoading, bool isSubmitting, bool isEditing, int maxContacts, ContactError? error
});
}
/// @nodoc
class _$ContactsViewStateCopyWithImpl<$Res>
implements $ContactsViewStateCopyWith<$Res> {
_$ContactsViewStateCopyWithImpl(this._self, this._then);
final ContactsViewState _self;
final $Res Function(ContactsViewState) _then;
/// Create a copy of ContactsViewState
/// 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? isSubmitting = null,Object? isEditing = null,Object? maxContacts = null,Object? error = freezed,}) {
return _then(_self.copyWith(
contacts: null == contacts ? _self.contacts : contacts // ignore: cast_nullable_to_non_nullable
as List<ContactEntity>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,isEditing: null == isEditing ? _self.isEditing : isEditing // ignore: cast_nullable_to_non_nullable
as bool,maxContacts: null == maxContacts ? _self.maxContacts : maxContacts // ignore: cast_nullable_to_non_nullable
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContactError?,
));
}
}
/// Adds pattern-matching-related methods to [ContactsViewState].
extension ContactsViewStatePatterns on ContactsViewState {
/// 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( _ContactsViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ContactsViewState() 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( _ContactsViewState value) $default,){
final _that = this;
switch (_that) {
case _ContactsViewState():
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( _ContactsViewState value)? $default,){
final _that = this;
switch (_that) {
case _ContactsViewState() 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<ContactEntity> contacts, bool isLoading, bool isSubmitting, bool isEditing, int maxContacts, ContactError? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ContactsViewState() when $default != null:
return $default(_that.contacts,_that.isLoading,_that.isSubmitting,_that.isEditing,_that.maxContacts,_that.error);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<ContactEntity> contacts, bool isLoading, bool isSubmitting, bool isEditing, int maxContacts, ContactError? error) $default,) {final _that = this;
switch (_that) {
case _ContactsViewState():
return $default(_that.contacts,_that.isLoading,_that.isSubmitting,_that.isEditing,_that.maxContacts,_that.error);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<ContactEntity> contacts, bool isLoading, bool isSubmitting, bool isEditing, int maxContacts, ContactError? error)? $default,) {final _that = this;
switch (_that) {
case _ContactsViewState() when $default != null:
return $default(_that.contacts,_that.isLoading,_that.isSubmitting,_that.isEditing,_that.maxContacts,_that.error);case _:
return null;
}
}
}
/// @nodoc
class _ContactsViewState implements ContactsViewState {
const _ContactsViewState({final List<ContactEntity> contacts = const [], this.isLoading = true, this.isSubmitting = false, this.isEditing = false, this.maxContacts = 10, this.error}): _contacts = contacts;
final List<ContactEntity> _contacts;
@override@JsonKey() List<ContactEntity> get contacts {
if (_contacts is EqualUnmodifiableListView) return _contacts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_contacts);
}
@override@JsonKey() final bool isLoading;
@override@JsonKey() final bool isSubmitting;
@override@JsonKey() final bool isEditing;
@override@JsonKey() final int maxContacts;
@override final ContactError? error;
/// Create a copy of ContactsViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ContactsViewStateCopyWith<_ContactsViewState> get copyWith => __$ContactsViewStateCopyWithImpl<_ContactsViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ContactsViewState&&const DeepCollectionEquality().equals(other._contacts, _contacts)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.isEditing, isEditing) || other.isEditing == isEditing)&&(identical(other.maxContacts, maxContacts) || other.maxContacts == maxContacts)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_contacts),isLoading,isSubmitting,isEditing,maxContacts,error);
@override
String toString() {
return 'ContactsViewState(contacts: $contacts, isLoading: $isLoading, isSubmitting: $isSubmitting, isEditing: $isEditing, maxContacts: $maxContacts, error: $error)';
}
}
/// @nodoc
abstract mixin class _$ContactsViewStateCopyWith<$Res> implements $ContactsViewStateCopyWith<$Res> {
factory _$ContactsViewStateCopyWith(_ContactsViewState value, $Res Function(_ContactsViewState) _then) = __$ContactsViewStateCopyWithImpl;
@override @useResult
$Res call({
List<ContactEntity> contacts, bool isLoading, bool isSubmitting, bool isEditing, int maxContacts, ContactError? error
});
}
/// @nodoc
class __$ContactsViewStateCopyWithImpl<$Res>
implements _$ContactsViewStateCopyWith<$Res> {
__$ContactsViewStateCopyWithImpl(this._self, this._then);
final _ContactsViewState _self;
final $Res Function(_ContactsViewState) _then;
/// Create a copy of ContactsViewState
/// 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? isSubmitting = null,Object? isEditing = null,Object? maxContacts = null,Object? error = freezed,}) {
return _then(_ContactsViewState(
contacts: null == contacts ? _self._contacts : contacts // ignore: cast_nullable_to_non_nullable
as List<ContactEntity>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,isEditing: null == isEditing ? _self.isEditing : isEditing // ignore: cast_nullable_to_non_nullable
as bool,maxContacts: null == maxContacts ? _self.maxContacts : maxContacts // ignore: cast_nullable_to_non_nullable
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContactError?,
));
}
}
// dart format on

View File

@@ -1,111 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import '../../../../core/data/models/update_contact_request_dto.dart';
import '../../../../core/domain/repositories/contacts_repository.dart';
import '../../../../core/providers/contacts_repository_provider.dart';
import '../../domain/entities/contact_error.dart';
import 'contacts_view_model.dart';
import 'edit_contact_view_state.dart';
final editContactViewModelProvider = NotifierProvider.autoDispose
.family<EditContactViewModel, EditContactViewState, String>(
EditContactViewModel.new,
);
class EditContactViewModel extends Notifier<EditContactViewState> {
EditContactViewModel(this._contactId);
final String _contactId;
late final TextEditingController nameController;
late final TextEditingController phoneController;
late final ContactsRepository _contactsRepository;
late final SfTrackingRepository _tracking;
@override
EditContactViewState build() {
_contactsRepository = ref.read(contactsRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
final contact = ref
.read(contactsViewModelProvider)
.contacts
.where((c) => c.id == _contactId)
.firstOrNull;
final parsed = contact?.phone;
nameController = TextEditingController(text: contact?.name ?? '');
phoneController = TextEditingController(
text: parsed?.nationalNumber ?? contact?.rawPhone ?? '',
);
ref.onDispose(() {
nameController.dispose();
phoneController.dispose();
});
return EditContactViewState(
contactId: _contactId,
isoCode: parsed?.isoCode ?? SfPhoneNumber.defaultIsoCode,
contact: contact,
);
}
void updateCountry(String isoCode) {
if (isoCode == state.isoCode) return;
state = state.copyWith(isoCode: isoCode);
}
void clearError() {
if (state.error != null) state = state.copyWith(error: null);
}
Future<bool> submit() async {
final contact = state.contact;
if (contact == null || state.isSubmitting) return false;
final name = nameController.text.trim();
if (name.isEmpty) {
state = state.copyWith(error: ContactError.nameRequired);
return false;
}
final phone = SfPhoneNumber.tryParse(
phoneController.text,
defaultIsoCode: state.isoCode,
);
if (phone == null) {
state = state.copyWith(error: ContactError.phoneInvalid);
return false;
}
state = state.copyWith(isSubmitting: true, error: null);
try {
await _contactsRepository.updateContact(
request: UpdateContactRequestDto(
id: contact.id,
name: name,
phone: phone.e164,
),
);
if (!ref.mounted) return false;
unawaited(_tracking.legacyContactsEdited());
await ref.read(contactsViewModelProvider.notifier).refresh();
return true;
} catch (_) {
if (!ref.mounted) return false;
state = state.copyWith(isSubmitting: false, error: ContactError.network);
return false;
}
}
}

View File

@@ -1,17 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/contact_entity.dart';
import '../../domain/entities/contact_error.dart';
part 'edit_contact_view_state.freezed.dart';
@freezed
abstract class EditContactViewState with _$EditContactViewState {
const factory EditContactViewState({
required String contactId,
required String isoCode,
ContactEntity? contact,
@Default(false) bool isSubmitting,
ContactError? error,
}) = _EditContactViewState;
}

View File

@@ -1,307 +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 'edit_contact_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$EditContactViewState {
String get contactId; String get isoCode; ContactEntity? get contact; bool get isSubmitting; ContactError? get error;
/// Create a copy of EditContactViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$EditContactViewStateCopyWith<EditContactViewState> get copyWith => _$EditContactViewStateCopyWithImpl<EditContactViewState>(this as EditContactViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is EditContactViewState&&(identical(other.contactId, contactId) || other.contactId == contactId)&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.contact, contact) || other.contact == contact)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,contactId,isoCode,contact,isSubmitting,error);
@override
String toString() {
return 'EditContactViewState(contactId: $contactId, isoCode: $isoCode, contact: $contact, isSubmitting: $isSubmitting, error: $error)';
}
}
/// @nodoc
abstract mixin class $EditContactViewStateCopyWith<$Res> {
factory $EditContactViewStateCopyWith(EditContactViewState value, $Res Function(EditContactViewState) _then) = _$EditContactViewStateCopyWithImpl;
@useResult
$Res call({
String contactId, String isoCode, ContactEntity? contact, bool isSubmitting, ContactError? error
});
$ContactEntityCopyWith<$Res>? get contact;
}
/// @nodoc
class _$EditContactViewStateCopyWithImpl<$Res>
implements $EditContactViewStateCopyWith<$Res> {
_$EditContactViewStateCopyWithImpl(this._self, this._then);
final EditContactViewState _self;
final $Res Function(EditContactViewState) _then;
/// Create a copy of EditContactViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? contactId = null,Object? isoCode = null,Object? contact = freezed,Object? isSubmitting = null,Object? error = freezed,}) {
return _then(_self.copyWith(
contactId: null == contactId ? _self.contactId : contactId // ignore: cast_nullable_to_non_nullable
as String,isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable
as String,contact: freezed == contact ? _self.contact : contact // ignore: cast_nullable_to_non_nullable
as ContactEntity?,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContactError?,
));
}
/// Create a copy of EditContactViewState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ContactEntityCopyWith<$Res>? get contact {
if (_self.contact == null) {
return null;
}
return $ContactEntityCopyWith<$Res>(_self.contact!, (value) {
return _then(_self.copyWith(contact: value));
});
}
}
/// Adds pattern-matching-related methods to [EditContactViewState].
extension EditContactViewStatePatterns on EditContactViewState {
/// 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( _EditContactViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _EditContactViewState() 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( _EditContactViewState value) $default,){
final _that = this;
switch (_that) {
case _EditContactViewState():
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( _EditContactViewState value)? $default,){
final _that = this;
switch (_that) {
case _EditContactViewState() 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 contactId, String isoCode, ContactEntity? contact, bool isSubmitting, ContactError? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _EditContactViewState() when $default != null:
return $default(_that.contactId,_that.isoCode,_that.contact,_that.isSubmitting,_that.error);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 contactId, String isoCode, ContactEntity? contact, bool isSubmitting, ContactError? error) $default,) {final _that = this;
switch (_that) {
case _EditContactViewState():
return $default(_that.contactId,_that.isoCode,_that.contact,_that.isSubmitting,_that.error);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 contactId, String isoCode, ContactEntity? contact, bool isSubmitting, ContactError? error)? $default,) {final _that = this;
switch (_that) {
case _EditContactViewState() when $default != null:
return $default(_that.contactId,_that.isoCode,_that.contact,_that.isSubmitting,_that.error);case _:
return null;
}
}
}
/// @nodoc
class _EditContactViewState implements EditContactViewState {
const _EditContactViewState({required this.contactId, required this.isoCode, this.contact, this.isSubmitting = false, this.error});
@override final String contactId;
@override final String isoCode;
@override final ContactEntity? contact;
@override@JsonKey() final bool isSubmitting;
@override final ContactError? error;
/// Create a copy of EditContactViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$EditContactViewStateCopyWith<_EditContactViewState> get copyWith => __$EditContactViewStateCopyWithImpl<_EditContactViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _EditContactViewState&&(identical(other.contactId, contactId) || other.contactId == contactId)&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.contact, contact) || other.contact == contact)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,contactId,isoCode,contact,isSubmitting,error);
@override
String toString() {
return 'EditContactViewState(contactId: $contactId, isoCode: $isoCode, contact: $contact, isSubmitting: $isSubmitting, error: $error)';
}
}
/// @nodoc
abstract mixin class _$EditContactViewStateCopyWith<$Res> implements $EditContactViewStateCopyWith<$Res> {
factory _$EditContactViewStateCopyWith(_EditContactViewState value, $Res Function(_EditContactViewState) _then) = __$EditContactViewStateCopyWithImpl;
@override @useResult
$Res call({
String contactId, String isoCode, ContactEntity? contact, bool isSubmitting, ContactError? error
});
@override $ContactEntityCopyWith<$Res>? get contact;
}
/// @nodoc
class __$EditContactViewStateCopyWithImpl<$Res>
implements _$EditContactViewStateCopyWith<$Res> {
__$EditContactViewStateCopyWithImpl(this._self, this._then);
final _EditContactViewState _self;
final $Res Function(_EditContactViewState) _then;
/// Create a copy of EditContactViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? contactId = null,Object? isoCode = null,Object? contact = freezed,Object? isSubmitting = null,Object? error = freezed,}) {
return _then(_EditContactViewState(
contactId: null == contactId ? _self.contactId : contactId // ignore: cast_nullable_to_non_nullable
as String,isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable
as String,contact: freezed == contact ? _self.contact : contact // ignore: cast_nullable_to_non_nullable
as ContactEntity?,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContactError?,
));
}
/// Create a copy of EditContactViewState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ContactEntityCopyWith<$Res>? get contact {
if (_self.contact == null) {
return null;
}
return $ContactEntityCopyWith<$Res>(_self.contact!, (value) {
return _then(_self.copyWith(contact: value));
});
}
}
// dart format on

View File

@@ -1,139 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:uuid/uuid.dart';
import '../../../../core/data/models/create_contact_request_dto.dart';
import '../../../../core/domain/repositories/contacts_repository.dart';
import '../../../../core/providers/contacts_repository_provider.dart';
import '../../domain/entities/contact_error.dart';
import 'contacts_view_model.dart';
import 'new_contact_view_state.dart';
final newContactViewModelProvider =
NotifierProvider.autoDispose<NewContactViewModel, NewContactViewState>(
NewContactViewModel.new,
);
class NewContactViewModel extends Notifier<NewContactViewState> {
static const _uuid = Uuid();
late final TextEditingController nameController;
late final TextEditingController phoneController;
late final ContactsRepository _contactsRepository;
late final SfTrackingRepository _tracking;
@override
NewContactViewState build() {
_contactsRepository = ref.read(contactsRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
nameController = TextEditingController();
phoneController = TextEditingController();
ref.onDispose(() {
nameController.dispose();
phoneController.dispose();
});
return const NewContactViewState();
}
void updateCountry(String isoCode) {
if (isoCode == state.isoCode) return;
state = state.copyWith(isoCode: isoCode);
}
void clearError() {
if (state.error != null) state = state.copyWith(error: null);
}
Future<void> pickContactFromDevice() async {
final response = await ref
.read(deviceContactPickerProvider)
.pick(hintIsoCode: state.isoCode);
if (response.outcome ==
DeviceContactPickOutcome.permissionPermanentlyDenied) {
state = state.copyWith(error: ContactError.contactsPermissionBlocked);
return;
}
final data = response.data;
if (data == null) return;
final parsed = data.parsedPhone;
if (parsed != null) {
state = state.copyWith(isoCode: parsed.isoCode, error: null);
phoneController.text = parsed.nationalNumber;
} else {
phoneController.text = data.rawNumber;
}
if (nameController.text.trim().isEmpty) {
nameController.text = data.displayName;
}
}
Future<void> openSystemSettings() =>
ref.read(deviceContactPickerProvider).openSystemSettings();
Future<bool> submit() async {
if (state.isSubmitting) return false;
final listState = ref.read(contactsViewModelProvider);
if (listState.contacts.length >= listState.maxContacts) {
state = state.copyWith(error: ContactError.maxReached);
return false;
}
final name = nameController.text.trim();
if (name.isEmpty) {
state = state.copyWith(error: ContactError.nameRequired);
return false;
}
final phone = SfPhoneNumber.tryParse(
phoneController.text,
defaultIsoCode: state.isoCode,
);
if (phone == null) {
state = state.copyWith(error: ContactError.phoneInvalid);
return false;
}
state = state.copyWith(isSubmitting: true, error: null);
try {
final user = await ref.read(userInfoProvider.future);
if (!ref.mounted) return false;
await _contactsRepository.createContact(
request: CreateContactRequestDto(
id: _uuid.v4(),
name: name,
phone: phone.e164,
userId: user.id,
),
);
if (!ref.mounted) return false;
unawaited(
_tracking.legacyContactsAdded(
totalCount: listState.contacts.length + 1,
),
);
await ref.read(contactsViewModelProvider.notifier).refresh();
return true;
} catch (_) {
if (!ref.mounted) return false;
state = state.copyWith(isSubmitting: false, error: ContactError.network);
return false;
}
}
}

View File

@@ -1,14 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/contact_error.dart';
part 'new_contact_view_state.freezed.dart';
@freezed
abstract class NewContactViewState with _$NewContactViewState {
const factory NewContactViewState({
@Default('ES') String isoCode,
@Default(false) bool isSubmitting,
ContactError? error,
}) = _NewContactViewState;
}

View File

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

View File

@@ -1,74 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:utils/utils.dart';
import '../../domain/entities/contact_entity.dart';
import '../state/contacts_view_model.dart';
import 'package:legacy_theme/legacy_theme.dart';
class ConfirmDeleteDialog extends ConsumerWidget {
final ContactEntity contact;
const ConfirmDeleteDialog({super.key, required this.contact});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 32, big: 30),
vertical: SizeUtils.getByScreen(small: 30, big: 28),
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(
SizeUtils.getByScreen(small: 12, big: 10),
),
),
width: SizeUtils.getByScreen(small: 360, big: 350),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.translate(I18n.deleteContactMessage),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 19, big: 18),
),
),
SizedBox(height: SizeUtils.getByScreen(small: 28, big: 27)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: PrimaryButton(
onPressed: () => Navigator.pop(context),
text: context.translate(I18n.cancel),
color: context.sfColors.legacyPrimary,
height: SizeUtils.getByScreen(small: 38, big: 36),
radius: SizeUtils.getByScreen(small: 32, big: 34),
),
),
SizedBox(width: SizeUtils.getByScreen(small: 4, big: 16)),
Expanded(
child: PrimaryButton(
onPressed: () async {
final vm = ref.read(contactsViewModelProvider.notifier);
await vm.deleteContact(contact);
if (context.mounted) Navigator.pop(context);
},
text: context.translate(I18n.delete),
color: Theme.of(context).colorScheme.error,
height: SizeUtils.getByScreen(small: 38, big: 36),
radius: SizeUtils.getByScreen(small: 32, big: 34),
),
),
],
),
],
),
);
}
}

View File

@@ -1,27 +1,26 @@
import 'package:design_system/design_system.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:device_management/src/features/contacts/domain/entities/contact_entity.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get_it/get_it.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:navigation/navigation.dart';
import 'package:utils/utils.dart';
import '../../domain/entities/contact_entity.dart';
import 'confirm_delete_dialog.dart';
class ContactCard extends ConsumerWidget {
final ContactEntity contact;
final bool isEditing;
final VoidCallback onDelete;
const ContactCard({
super.key,
required this.contact,
required this.isEditing,
required this.onDelete,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 22, big: 21),
@@ -73,14 +72,10 @@ class ContactCard extends ConsumerWidget {
DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.all(Radius.circular(12)),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: IconButton(
onPressed: () => showDialog(
context: context,
builder: (_) =>
Dialog(child: ConfirmDeleteDialog(contact: contact)),
),
onPressed: onDelete,
icon: const Icon(Icons.close, color: Colors.white),
),
),
@@ -88,7 +83,7 @@ class ContactCard extends ConsumerWidget {
DecoratedBox(
decoration: BoxDecoration(
color: context.sfColors.legacyPrimary,
borderRadius: BorderRadius.all(Radius.circular(12)),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: IconButton(
onPressed: () {

View File

@@ -1,43 +1,121 @@
import 'package:design_system/design_system.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_controller.dart';
import 'package:device_management/src/features/contacts/presentation/providers/new_contact_form_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
import '../../domain/entities/contact_error.dart';
import '../state/new_contact_view_model.dart';
import 'package:legacy_theme/legacy_theme.dart';
class NewContactDialog extends ConsumerStatefulWidget {
final String userId;
final int currentCount;
final int maxContacts;
class NewContactDialog extends ConsumerWidget {
const NewContactDialog({super.key});
const NewContactDialog({
super.key,
required this.userId,
required this.currentCount,
required this.maxContacts,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(newContactViewModelProvider.notifier);
final isoCode = ref.watch(
newContactViewModelProvider.select((s) => s.isoCode),
);
final isSubmitting = ref.watch(
newContactViewModelProvider.select((s) => s.isSubmitting),
);
ConsumerState<NewContactDialog> createState() => _NewContactDialogState();
}
ref.listen(newContactViewModelProvider.select((s) => s.error), (_, next) {
if (next == null) return;
if (next == ContactError.contactsPermissionBlocked) {
showContactsPermissionDialog(
context,
onOpenSettings: vm.openSystemSettings,
class _NewContactDialogState extends ConsumerState<NewContactDialog> {
final _nameController = TextEditingController();
final _phoneController = TextEditingController();
@override
void initState() {
super.initState();
_nameController.addListener(_clearErrorIfAny);
_phoneController.addListener(_clearErrorIfAny);
}
@override
void dispose() {
_nameController.removeListener(_clearErrorIfAny);
_phoneController.removeListener(_clearErrorIfAny);
_nameController.dispose();
_phoneController.dispose();
super.dispose();
}
void _clearErrorIfAny() {
ref.read(newContactFormProvider.notifier).clearError();
}
Future<void> _pickContact() async {
final isoCode = ref.read(newContactFormProvider).isoCode;
final response = await ref
.read(deviceContactPickerProvider)
.pick(hintIsoCode: isoCode);
if (response.outcome ==
DeviceContactPickOutcome.permissionPermanentlyDenied) {
if (!mounted) return;
showContactsPermissionDialog(
context,
onOpenSettings:
ref.read(deviceContactPickerProvider).openSystemSettings,
);
return;
}
final data = response.data;
if (data == null) return;
final parsed = data.parsedPhone;
if (parsed != null) {
ref.read(newContactFormProvider.notifier).setIsoCode(parsed.isoCode);
_phoneController.text = parsed.nationalNumber;
} else {
_phoneController.text = data.rawNumber;
}
if (_nameController.text.trim().isEmpty) {
_nameController.text = data.displayName;
}
}
void _submit() {
final formNotifier = ref.read(newContactFormProvider.notifier);
final name = _nameController.text.trim();
if (name.isEmpty) {
formNotifier.setLocalError(I18n.errorFirstNameRequired);
return;
}
final isoCode = ref.read(newContactFormProvider).isoCode;
final parsed = SfPhoneNumber.tryParse(
_phoneController.text,
defaultIsoCode: isoCode,
);
if (parsed == null) {
formNotifier.setLocalError(I18n.errorMessagePhoneIsInvalid);
return;
}
formNotifier.clearError();
Navigator.of(context).pop();
ref.read(contactsControllerProvider.notifier).createContact(
userId: widget.userId,
name: name,
phoneE164: parsed.e164,
currentCount: widget.currentCount,
maxCount: widget.maxContacts,
);
} else {
showTopSnackbar(
context,
message: context.translate(next.i18nKey),
type: MessageType.error,
);
}
vm.clearError();
});
}
@override
Widget build(BuildContext context) {
final primaryColor = context.sfColors.legacyPrimary;
final formState = ref.watch(newContactFormProvider);
final isSubmitting =
ref.watch(contactsControllerProvider.select((s) => s.isLoading));
return Container(
padding: EdgeInsets.symmetric(
@@ -61,17 +139,14 @@ class NewContactDialog extends ConsumerWidget {
alignment: Alignment.topRight,
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(
Icons.close,
color: context.sfColors.legacyPrimary,
),
icon: Icon(Icons.close, color: primaryColor),
),
),
Center(
child: Text(
context.translate(I18n.newContact).toUpperCase(),
style: TextStyle(
color: context.sfColors.legacyPrimary,
color: primaryColor,
fontWeight: FontWeight.w500,
),
),
@@ -85,7 +160,7 @@ class NewContactDialog extends ConsumerWidget {
SizedBox(height: SizeUtils.getByScreen(small: 14, big: 12)),
CustomTextField(
hint: context.translate(I18n.name),
controller: vm.nameController,
controller: _nameController,
),
SizedBox(height: SizeUtils.getByScreen(small: 14, big: 12)),
Row(
@@ -93,16 +168,20 @@ class NewContactDialog extends ConsumerWidget {
children: [
CountryPrefixPicker(
headerText: context.translate(I18n.selectYourCountry),
initialSelection: isoCode,
initialSelection: formState.isoCode,
onChanged: (country) {
final code = country.code;
if (code != null) vm.updateCountry(code);
if (code != null) {
ref
.read(newContactFormProvider.notifier)
.setIsoCode(code);
}
},
width: 80,
),
Expanded(
child: CustomTextField(
controller: vm.phoneController,
controller: _phoneController,
hint: context.translate(I18n.phoneNumber),
keyboardType: TextInputType.phone,
),
@@ -110,10 +189,10 @@ class NewContactDialog extends ConsumerWidget {
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.sfColors.legacyPrimary,
color: primaryColor,
),
child: IconButton(
onPressed: vm.pickContactFromDevice,
onPressed: _pickContact,
icon: Icon(
SFIcons.contactsCircle,
color: Colors.white,
@@ -123,6 +202,17 @@ class NewContactDialog extends ConsumerWidget {
),
],
),
if (formState.localError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
context.translate(formState.localError!),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
),
SizedBox(height: SizeUtils.getByScreen(small: 14, big: 12)),
Padding(
padding: EdgeInsets.symmetric(
@@ -130,14 +220,9 @@ class NewContactDialog extends ConsumerWidget {
vertical: SizeUtils.getByScreen(small: 10, big: 8),
),
child: PrimaryButton(
onPressed: isSubmitting
? null
: () async {
final success = await vm.submit();
if (success && context.mounted) Navigator.pop(context);
},
onPressed: isSubmitting ? null : _submit,
text: context.translate(I18n.save),
color: context.sfColors.legacyPrimary,
color: primaryColor,
),
),
],
@@ -146,4 +231,3 @@ class NewContactDialog extends ConsumerWidget {
);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:device_management/src/core/data/models/create_contact_request_dto.dart';
import 'package:device_management/src/core/data/models/update_contact_request_dto.dart';
import 'package:device_management/src/core/domain/repositories/contacts_repository.dart';
import 'package:device_management/src/core/providers/contacts_repository_provider.dart';
import 'package:device_management/src/features/contacts/presentation/providers/contacts_controller.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/testing.dart';
import 'package:sf_tracking/sf_tracking.dart';
class MockContactsRepository extends Mock implements ContactsRepository {}
void main() {
setUpAll(() {
registerFallbackValue(
const CreateContactRequestDto(
id: 'x',
name: 'n',
phone: 'p',
userId: 'u',
),
);
registerFallbackValue(
const UpdateContactRequestDto(id: 'x', name: 'n', phone: 'p'),
);
});
ProviderContainer buildContainer(ContactsRepository repo) {
return makeContainer(
overrides: [
contactsRepositoryProvider.overrideWithValue(repo),
sfTrackingProvider.overrideWithValue(
SfTrackingRepository(clients: const []),
),
],
);
}
group('ContactsController.createContact', () {
test('blocks when at max', () async {
final repo = MockContactsRepository();
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(contactsControllerProvider.notifier).createContact(
userId: 'u1',
name: 'N',
phoneE164: '+34600000001',
currentCount: 10,
maxCount: 10,
);
final state = container.read(contactsControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, isA<ContactsMaxReachedException>());
verifyNever(() => repo.createContact(request: any(named: 'request')));
});
test('creates contact via repository on success', () async {
final repo = MockContactsRepository();
when(() => repo.createContact(request: any(named: 'request')))
.thenAnswer((_) async {});
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(contactsControllerProvider.notifier).createContact(
userId: 'u1',
name: 'N',
phoneE164: '+34600000001',
currentCount: 5,
maxCount: 10,
);
expect(
container.read(contactsControllerProvider),
isA<AsyncData<void>>(),
);
verify(() => repo.createContact(request: any(named: 'request')))
.called(1);
});
});
group('ContactsController.deleteContact', () {
test('blocks when only one contact', () async {
final repo = MockContactsRepository();
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(contactsControllerProvider.notifier).deleteContact(
userId: 'u1',
contactId: 'c1',
currentCount: 1,
);
final state = container.read(contactsControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, isA<ContactsMinReachedException>());
verifyNever(() => repo.deleteContact(contactId: any(named: 'contactId')));
});
test('deletes via repository on success', () async {
final repo = MockContactsRepository();
when(() => repo.deleteContact(contactId: any(named: 'contactId')))
.thenAnswer((_) async {});
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(contactsControllerProvider.notifier).deleteContact(
userId: 'u1',
contactId: 'c1',
currentCount: 3,
);
expect(
container.read(contactsControllerProvider),
isA<AsyncData<void>>(),
);
verify(() => repo.deleteContact(contactId: 'c1')).called(1);
});
});
group('ContactsController.updateContact', () {
test('updates via repository on success', () async {
final repo = MockContactsRepository();
when(() => repo.updateContact(request: any(named: 'request')))
.thenAnswer((_) async {});
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(contactsControllerProvider.notifier).updateContact(
userId: 'u1',
contactId: 'c1',
name: 'N',
phoneE164: '+34600000001',
);
expect(
container.read(contactsControllerProvider),
isA<AsyncData<void>>(),
);
expect(
container.read(contactsControllerProvider.notifier).lastAction,
ContactsAction.update,
);
});
test('exposes AsyncError when repository fails', () async {
final repo = MockContactsRepository();
when(() => repo.updateContact(request: any(named: 'request')))
.thenThrow(const ApiException(message: 'boom', isNetworkError: true));
final container = buildContainer(repo);
addTearDown(container.dispose);
await container.read(contactsControllerProvider.notifier).updateContact(
userId: 'u1',
contactId: 'c1',
name: 'N',
phoneE164: '+34600000001',
);
expect(
container.read(contactsControllerProvider),
isA<AsyncError<void>>(),
);
});
});
}

View File

@@ -586,6 +586,9 @@
"numberAdded": "Nummer erfolgreich hinzugefügt",
"numberRemoved": "Nummer erfolgreich entfernt",
"numberUpdated": "Nummer erfolgreich aktualisiert",
"contactAdded": "Kontakt erfolgreich hinzugefügt",
"contactUpdated": "Kontakt erfolgreich aktualisiert",
"contactDeleted": "Kontakt erfolgreich gelöscht",
"editAllowedNumber": "Erlaubte Nummer bearbeiten",
"addSosContact": "SOS-Kontakt hinzufügen",
"noSosContacts": "Keine SOS-Kontakte konfiguriert",

View File

@@ -762,6 +762,9 @@
"numberAdded": "Number added successfully",
"numberRemoved": "Number removed successfully",
"numberUpdated": "Number updated successfully",
"contactAdded": "Contact added successfully",
"contactUpdated": "Contact updated successfully",
"contactDeleted": "Contact deleted successfully",
"editAllowedNumber": "Edit allowed number",
"addSosContact": "Add SOS contact",
"noSosContacts": "No SOS contacts configured",

View File

@@ -763,6 +763,9 @@
"numberAdded": "Número añadido correctamente",
"numberRemoved": "Número eliminado correctamente",
"numberUpdated": "Número actualizado correctamente",
"contactAdded": "Contacto añadido correctamente",
"contactUpdated": "Contacto actualizado correctamente",
"contactDeleted": "Contacto eliminado correctamente",
"editAllowedNumber": "Editar número permitido",
"addSosContact": "Añadir contacto SOS",
"noSosContacts": "No hay contactos SOS configurados",

View File

@@ -586,6 +586,9 @@
"numberAdded": "Numéro ajouté avec succès",
"numberRemoved": "Numéro supprimé avec succès",
"numberUpdated": "Numéro mis à jour avec succès",
"contactAdded": "Contact ajouté avec succès",
"contactUpdated": "Contact mis à jour avec succès",
"contactDeleted": "Contact supprimé avec succès",
"editAllowedNumber": "Modifier le numéro autorisé",
"addSosContact": "Ajouter un contact SOS",
"noSosContacts": "Aucun contact SOS configuré",

View File

@@ -586,6 +586,9 @@
"numberAdded": "Numero aggiunto con successo",
"numberRemoved": "Numero rimosso con successo",
"numberUpdated": "Numero aggiornato con successo",
"contactAdded": "Contatto aggiunto con successo",
"contactUpdated": "Contatto aggiornato con successo",
"contactDeleted": "Contatto eliminato con successo",
"editAllowedNumber": "Modifica numero consentito",
"addSosContact": "Aggiungi contatto SOS",
"noSosContacts": "Nessun contatto SOS configurato",

View File

@@ -586,6 +586,9 @@
"numberAdded": "Número adicionado com sucesso",
"numberRemoved": "Número removido com sucesso",
"numberUpdated": "Número atualizado com sucesso",
"contactAdded": "Contacto adicionado com sucesso",
"contactUpdated": "Contacto atualizado com sucesso",
"contactDeleted": "Contacto eliminado com sucesso",
"editAllowedNumber": "Editar número permitido",
"addSosContact": "Adicionar contacto SOS",
"noSosContacts": "Nenhum contacto SOS configurado",

View File

@@ -200,8 +200,11 @@ class I18n {
static const String close = 'close';
static const String confirm = 'confirm';
static const String connect = 'connect';
static const String contactAdded = 'contactAdded';
static const String contactDeleted = 'contactDeleted';
static const String contactName = 'contactName';
static const String contactTitle = 'contactTitle';
static const String contactUpdated = 'contactUpdated';
static const String contactsAgendaTitle = 'contactsAgendaTitle';
static const String contactsEmpty = 'contactsEmpty';
static const String contactsEmptyHint = 'contactsEmptyHint';