refactor(device_management): migrate remote_connection to Riverpod

This commit is contained in:
2026-04-22 22:21:35 +02:00
parent 94e2fcbf7d
commit 734bd79af7
17 changed files with 807 additions and 782 deletions

View File

@@ -0,0 +1,95 @@
import 'dart:async';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_pictures_provider.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_tracking/sf_tracking.dart';
part 'remote_camera_controller.g.dart';
const int _photoWaitSeconds = 5;
class RemoteCameraState {
const RemoteCameraState({
this.isTakingPicture = false,
this.isWaitingForPhoto = false,
this.photoCountdown = 0,
});
final bool isTakingPicture;
final bool isWaitingForPhoto;
final int photoCountdown;
RemoteCameraState copyWith({
bool? isTakingPicture,
bool? isWaitingForPhoto,
int? photoCountdown,
}) {
return RemoteCameraState(
isTakingPicture: isTakingPicture ?? this.isTakingPicture,
isWaitingForPhoto: isWaitingForPhoto ?? this.isWaitingForPhoto,
photoCountdown: photoCountdown ?? this.photoCountdown,
);
}
}
@riverpod
class RemoteCameraController extends _$RemoteCameraController {
Timer? _timer;
@override
RemoteCameraState build() {
ref.onDispose(() => _timer?.cancel());
return const RemoteCameraState();
}
Future<void> takePicture(String deviceIdentificator) async {
state = state.copyWith(
isTakingPicture: true,
isWaitingForPhoto: false,
photoCountdown: 0,
);
try {
await ref.read(commandsRepositoryProvider).send(
request: SendCommandRequestModel(
device: deviceIdentificator,
command: DeviceCommand.requestPhoto,
),
);
} catch (e) {
state = const RemoteCameraState();
rethrow;
}
unawaited(
ref.read(sfTrackingProvider).legacyDeviceRemoteConnectionPhotoTaken(),
);
state = state.copyWith(
isTakingPicture: false,
isWaitingForPhoto: true,
photoCountdown: _photoWaitSeconds,
);
_timer?.cancel();
final completer = Completer<void>();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
final remaining = state.photoCountdown - 1;
if (remaining <= 0) {
timer.cancel();
_timer = null;
state = state.copyWith(
isWaitingForPhoto: false,
photoCountdown: 0,
);
if (!completer.isCompleted) completer.complete();
} else {
state = state.copyWith(photoCountdown: remaining);
}
});
await completer.future;
ref.invalidate(remotePicturesProvider(deviceIdentificator));
}
}

View File

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

View File

@@ -0,0 +1,43 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_tracking/sf_tracking.dart';
part 'remote_picture_index_provider.g.dart';
@riverpod
class RemotePictureIndex extends _$RemotePictureIndex {
@override
int build() => 0;
void prev({required int total}) {
if (total <= 0) return;
unawaited(
ref
.read(sfTrackingProvider)
.legacyDeviceRemoteConnectionPictureViewed('prev'),
);
final next = state - 1;
state = next < 0 ? total - 1 : next;
}
void next({required int total}) {
if (total <= 0) return;
unawaited(
ref
.read(sfTrackingProvider)
.legacyDeviceRemoteConnectionPictureViewed('next'),
);
state = (state + 1) % total;
}
void set(int value) {
if (value == state) return;
unawaited(
ref
.read(sfTrackingProvider)
.legacyDeviceRemoteConnectionPictureViewed('direct'),
);
state = value;
}
}

View File

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

View File

@@ -0,0 +1,15 @@
import 'package:device_management/src/core/providers/pictures_repository_provider.dart';
import 'package:device_management/src/features/remote_connection/domain/entities/picture_entity.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'remote_pictures_provider.g.dart';
@riverpod
Future<List<PictureEntity>> remotePictures(
Ref ref,
String deviceIdentificator,
) async {
return ref
.read(picturesRepositoryProvider)
.getPictures(deviceId: deviceIdentificator);
}

View File

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

View File

@@ -0,0 +1,38 @@
import 'dart:async';
import 'package:legacy_device_state/legacy_device_state.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_tracking/sf_tracking.dart';
part 'spy_call_controller.g.dart';
class SpyCallInvalidPhoneException implements Exception {
const SpyCallInvalidPhoneException();
}
@riverpod
class SpyCallController extends _$SpyCallController {
@override
FutureOr<void> build() {}
Future<void> call({
required String deviceIdentificator,
required String phoneE164,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(commandsRepositoryProvider).send(
request: SendCommandRequestModel(
device: deviceIdentificator,
command: DeviceCommand.callCenter,
data: {'phone_number': phoneE164},
),
);
unawaited(
ref
.read(sfTrackingProvider)
.legacyDeviceRemoteConnectionCallInitiated(),
);
});
}
}

View File

@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'spy_call_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SpyCallController)
const spyCallControllerProvider = SpyCallControllerProvider._();
final class SpyCallControllerProvider
extends $AsyncNotifierProvider<SpyCallController, void> {
const SpyCallControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'spyCallControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$spyCallControllerHash();
@$internal
@override
SpyCallController create() => SpyCallController();
}
String _$spyCallControllerHash() => r'f5e88f8b050d5461d60836f0c288f987f9ed3604';
abstract class _$SpyCallController 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,14 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'spy_call_form_provider.g.dart';
@riverpod
class SpyCallForm extends _$SpyCallForm {
@override
String build() => 'ES';
void setIsoCode(String code) {
if (code == state) return;
state = code;
}
}

View File

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

View File

@@ -1,16 +1,19 @@
import 'package:design_system/design_system.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:device_management/src/features/remote_connection/domain/entities/picture_entity.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_camera_controller.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_picture_index_provider.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_pictures_provider.dart';
import 'package:device_management/src/features/remote_connection/presentation/widgets/show_picture_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:lottie/lottie.dart';
import 'package:device_management/src/features/remote_connection/presentation/state/remote_connection_view_model.dart';
import 'package:device_management/src/features/remote_connection/presentation/state/remote_connection_view_state.dart';
import 'package:device_management/src/features/remote_connection/presentation/widgets/show_picture_dialog.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 'package:legacy_device_state/legacy_device_state.dart';
import 'package:legacy_ui/legacy_ui.dart';
class RemoteCameraScreen extends ConsumerWidget {
final NavigationContract navigationContract;
@@ -19,88 +22,66 @@ class RemoteCameraScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(remoteConnectionViewModelProvider.select((s) => s.errorEvent), (
_,
next,
) {
if (next != null) {
final message = switch (next) {
RemoteConnectionErrorEvent.takePicture => context.translate(
I18n.errorTakePicture,
),
RemoteConnectionErrorEvent.fetchPhotos => context.translate(
I18n.errorFetchPhotos,
),
RemoteConnectionErrorEvent.call => context.translate(I18n.errorCall),
RemoteConnectionErrorEvent.invalidPhone => context.translate(
I18n.errorMessagePhoneIsInvalid,
),
};
showTopSnackbar(context, message: message, type: MessageType.error);
}
});
final device = ref.watch(selectedDeviceProvider).value;
ref.listen(
remoteConnectionViewModelProvider.select((s) => s.successEvent),
(_, next) {
if (next != null) {
final message = switch (next) {
RemoteConnectionSuccessEvent.photoTaken => context.translate(
I18n.photoTaken,
),
};
showTopSnackbar(context, message: message, type: MessageType.success);
ref.read(remoteConnectionViewModelProvider.notifier).clearSuccess();
}
},
);
if (device == null) {
return LegacyPageLayout(
title: context.translate(I18n.remoteCamera),
body: const Center(child: CircularProgressIndicator()),
);
}
final isLoading = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.isLoadingPictures),
);
final isTaking = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.isTakingPicture),
);
final isWaiting = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.isWaitingForPhoto),
);
final countdown = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.photoCountdown),
);
final identificator = device.identificator;
final cameraState = ref.watch(remoteCameraControllerProvider);
final picturesAsync = ref.watch(remotePicturesProvider(identificator));
Widget body;
if (isLoading || isTaking) {
if (cameraState.isTakingPicture) {
body = const Center(child: CircularProgressIndicator());
} else if (isWaiting) {
body = _WaitingForPhotoOverlay(remainingSeconds: countdown);
} else if (cameraState.isWaitingForPhoto) {
body = _WaitingForPhotoOverlay(
remainingSeconds: cameraState.photoCountdown,
);
} else {
body = const _GallerySection();
body = picturesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
Center(child: Text(context.translate(I18n.errorFetchPhotos))),
data: (pictures) => _GallerySection(pictures: pictures),
);
}
return LegacyPageLayout(
title: context.translate(I18n.remoteCamera),
body: body,
footer: isWaiting ? const SizedBox.shrink() : const _TakePictureSection(),
footer: cameraState.isWaitingForPhoto
? const SizedBox.shrink()
: _TakePictureSection(deviceIdentificator: identificator),
);
}
}
class _GallerySection extends ConsumerWidget {
const _GallerySection();
final List<PictureEntity> pictures;
const _GallerySection({required this.pictures});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(remoteConnectionViewModelProvider.notifier);
final pictures = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.pictures),
);
if (pictures.isEmpty) {
return Center(
child: Text(
context.translate(I18n.noPhotosAvailable),
style: const TextStyle(color: Colors.grey),
),
);
}
return GridView.count(
primary: false,
padding: SizeUtils.getByScreen(
small: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
big: EdgeInsets.symmetric(horizontal: 23, vertical: 11),
small: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
big: const EdgeInsets.symmetric(horizontal: 23, vertical: 11),
),
crossAxisSpacing: 11,
mainAxisSpacing: 11,
@@ -110,10 +91,10 @@ class _GallerySection extends ConsumerWidget {
pictures.length,
(int index) => TextButton(
onPressed: () {
vm.setPictureIndex(index);
showDialog(
ref.read(remotePictureIndexProvider.notifier).set(index);
showDialog<void>(
context: context,
builder: (context) => Dialog(child: ShowPictureDialog()),
builder: (_) => const Dialog(child: ShowPictureDialog()),
);
},
child: Container(
@@ -135,21 +116,31 @@ class _GallerySection extends ConsumerWidget {
}
class _TakePictureSection extends ConsumerWidget {
const _TakePictureSection();
final String deviceIdentificator;
const _TakePictureSection({required this.deviceIdentificator});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(remoteConnectionViewModelProvider.notifier);
return Padding(
padding: SizeUtils.getByScreen(
small: EdgeInsets.symmetric(vertical: 12, horizontal: 26),
big: EdgeInsets.symmetric(vertical: 10, horizontal: 25),
small: const EdgeInsets.symmetric(vertical: 12, horizontal: 26),
big: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
),
child: PrimaryButton(
onPressed: () async {
if (!await guardDeviceCommand(context, ref)) return;
vm.takePicture();
if (!context.mounted) return;
try {
await ref
.read(remoteCameraControllerProvider.notifier)
.takePicture(deviceIdentificator);
if (!context.mounted) return;
await showSuccessDialog(context, I18n.photoTaken);
} catch (_) {
if (!context.mounted) return;
await showErrorDialog(context, I18n.errorTakePicture);
}
},
text: context.translate(I18n.takePicture),
color: context.sfColors.legacyPrimary,

View File

@@ -1,220 +0,0 @@
import 'dart:async';
import 'package:device_management/src/core/providers/pictures_repository_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:device_management/src/features/remote_connection/presentation/state/remote_connection_view_state.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import '../../../../core/domain/repositories/pictures_repository.dart';
final remoteConnectionViewModelProvider =
NotifierProvider.autoDispose<
RemoteConnectionViewModel,
RemoteConnectionViewState
>(RemoteConnectionViewModel.new);
class RemoteConnectionViewModel extends Notifier<RemoteConnectionViewState> {
late final TextEditingController phoneController;
late final CommandsRepository _commandsRepository;
late final PicturesRepository _picturesRepository;
late final SfTrackingRepository _tracking;
Timer? _photoTimer;
static const int _photoWaitSeconds = 5;
@override
RemoteConnectionViewState build() {
_commandsRepository = ref.read(commandsRepositoryProvider);
_picturesRepository = ref.read(picturesRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
unawaited(_tracking.legacyDeviceRemoteConnectionStarted());
phoneController = TextEditingController();
phoneController.addListener(_onPhoneChanged);
ref.onDispose(disposeControllers);
Future.microtask(load);
return const RemoteConnectionViewState();
}
Future<void> load() async {
final device = ref.read(selectedDeviceProvider).value;
if (device == null) return;
state = state.copyWith(deviceId: device.identificator);
try {
final pictures = await _picturesRepository.getPictures(
deviceId: device.identificator,
);
if (!ref.mounted) return;
state = state.copyWith(pictures: pictures, isLoadingPictures: false);
} catch (_) {
if (!ref.mounted) return;
state = state.copyWith(isLoadingPictures: false);
}
}
void _onPhoneChanged() {
final text = phoneController.text;
if (text == state.phone) return;
state = state.copyWith(phone: text, errorEvent: null);
}
void updateCountry(String isoCode) {
if (isoCode == state.isoCode) return;
state = state.copyWith(isoCode: isoCode, errorEvent: null);
}
void prevPicture() {
int pictureIndex = state.pictureIndex - 1;
if (pictureIndex < 0) {
pictureIndex = state.pictures.length - 1;
}
unawaited(_tracking.legacyDeviceRemoteConnectionPictureViewed('prev'));
state = state.copyWith(pictureIndex: pictureIndex);
}
void nextPicture() {
int pictureIndex = (state.pictureIndex + 1) % state.pictures.length;
unawaited(_tracking.legacyDeviceRemoteConnectionPictureViewed('next'));
state = state.copyWith(pictureIndex: pictureIndex);
}
void setPictureIndex(int value) {
unawaited(_tracking.legacyDeviceRemoteConnectionPictureViewed('direct'));
state = state.copyWith(pictureIndex: value);
}
void clearSuccess() {
state = state.copyWith(successEvent: null);
}
Future<void> takePicture() async {
try {
state = state.copyWith(
isTakingPicture: true,
successEvent: null,
errorEvent: null,
);
final request = SendCommandRequestModel(
device: state.deviceId,
command: DeviceCommand.requestPhoto,
);
await _commandsRepository.send(request: request);
if (!ref.mounted) return;
unawaited(_tracking.legacyDeviceRemoteConnectionPhotoTaken());
state = state.copyWith(
isTakingPicture: false,
isWaitingForPhoto: true,
photoCountdown: _photoWaitSeconds,
);
_startPhotoCountdown();
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(
isTakingPicture: false,
errorEvent: RemoteConnectionErrorEvent.takePicture,
);
}
}
void _startPhotoCountdown() {
_photoTimer?.cancel();
_photoTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!ref.mounted) {
timer.cancel();
return;
}
final remaining = state.photoCountdown - 1;
if (remaining <= 0) {
timer.cancel();
_photoTimer = null;
_fetchPhotosAfterCapture();
} else {
state = state.copyWith(photoCountdown: remaining);
}
});
}
Future<void> _fetchPhotosAfterCapture() async {
try {
final pictures = await _picturesRepository.getPictures(
deviceId: state.deviceId,
);
if (!ref.mounted) return;
state = state.copyWith(
isWaitingForPhoto: false,
photoCountdown: 0,
pictures: pictures,
successEvent: RemoteConnectionSuccessEvent.photoTaken,
);
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(
isWaitingForPhoto: false,
photoCountdown: 0,
errorEvent: RemoteConnectionErrorEvent.fetchPhotos,
);
}
}
Future<void> call() async {
final parsed = SfPhoneNumber.tryParse(
phoneController.text,
defaultIsoCode: state.isoCode,
);
if (parsed == null) {
state = state.copyWith(
errorEvent: RemoteConnectionErrorEvent.invalidPhone,
);
return;
}
try {
state = state.copyWith(isCalling: true);
final request = SendCommandRequestModel(
device: state.deviceId,
command: DeviceCommand.callCenter,
data: {'phone_number': parsed.e164},
);
await _commandsRepository.send(request: request);
if (!ref.mounted) return;
unawaited(_tracking.legacyDeviceRemoteConnectionCallInitiated());
state = state.copyWith(isCalling: false);
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(
isCalling: false,
errorEvent: RemoteConnectionErrorEvent.call,
);
}
}
void disposeControllers() {
phoneController.removeListener(_onPhoneChanged);
phoneController.dispose();
}
}

View File

@@ -1,26 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:device_management/src/features/remote_connection/domain/entities/picture_entity.dart';
part 'remote_connection_view_state.freezed.dart';
enum RemoteConnectionErrorEvent { takePicture, fetchPhotos, call, invalidPhone }
enum RemoteConnectionSuccessEvent { photoTaken }
@freezed
abstract class RemoteConnectionViewState with _$RemoteConnectionViewState {
const factory RemoteConnectionViewState({
@Default('') String deviceId,
@Default('ES') String isoCode,
@Default('') String phone,
@Default([]) List<PictureEntity> pictures,
@Default(0) int pictureIndex,
@Default(true) bool isLoadingPictures,
@Default(false) bool isTakingPicture,
@Default(false) bool isWaitingForPhoto,
@Default(0) int photoCountdown,
@Default(false) bool isCalling,
RemoteConnectionErrorEvent? errorEvent,
RemoteConnectionSuccessEvent? successEvent,
}) = _RemoteConnectionViewState;
}

View File

@@ -1,310 +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 'remote_connection_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RemoteConnectionViewState {
String get deviceId; String get isoCode; String get phone; List<PictureEntity> get pictures; int get pictureIndex; bool get isLoadingPictures; bool get isTakingPicture; bool get isWaitingForPhoto; int get photoCountdown; bool get isCalling; RemoteConnectionErrorEvent? get errorEvent; RemoteConnectionSuccessEvent? get successEvent;
/// Create a copy of RemoteConnectionViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RemoteConnectionViewStateCopyWith<RemoteConnectionViewState> get copyWith => _$RemoteConnectionViewStateCopyWithImpl<RemoteConnectionViewState>(this as RemoteConnectionViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RemoteConnectionViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.phone, phone) || other.phone == phone)&&const DeepCollectionEquality().equals(other.pictures, pictures)&&(identical(other.pictureIndex, pictureIndex) || other.pictureIndex == pictureIndex)&&(identical(other.isLoadingPictures, isLoadingPictures) || other.isLoadingPictures == isLoadingPictures)&&(identical(other.isTakingPicture, isTakingPicture) || other.isTakingPicture == isTakingPicture)&&(identical(other.isWaitingForPhoto, isWaitingForPhoto) || other.isWaitingForPhoto == isWaitingForPhoto)&&(identical(other.photoCountdown, photoCountdown) || other.photoCountdown == photoCountdown)&&(identical(other.isCalling, isCalling) || other.isCalling == isCalling)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent));
}
@override
int get hashCode => Object.hash(runtimeType,deviceId,isoCode,phone,const DeepCollectionEquality().hash(pictures),pictureIndex,isLoadingPictures,isTakingPicture,isWaitingForPhoto,photoCountdown,isCalling,errorEvent,successEvent);
@override
String toString() {
return 'RemoteConnectionViewState(deviceId: $deviceId, isoCode: $isoCode, phone: $phone, pictures: $pictures, pictureIndex: $pictureIndex, isLoadingPictures: $isLoadingPictures, isTakingPicture: $isTakingPicture, isWaitingForPhoto: $isWaitingForPhoto, photoCountdown: $photoCountdown, isCalling: $isCalling, errorEvent: $errorEvent, successEvent: $successEvent)';
}
}
/// @nodoc
abstract mixin class $RemoteConnectionViewStateCopyWith<$Res> {
factory $RemoteConnectionViewStateCopyWith(RemoteConnectionViewState value, $Res Function(RemoteConnectionViewState) _then) = _$RemoteConnectionViewStateCopyWithImpl;
@useResult
$Res call({
String deviceId, String isoCode, String phone, List<PictureEntity> pictures, int pictureIndex, bool isLoadingPictures, bool isTakingPicture, bool isWaitingForPhoto, int photoCountdown, bool isCalling, RemoteConnectionErrorEvent? errorEvent, RemoteConnectionSuccessEvent? successEvent
});
}
/// @nodoc
class _$RemoteConnectionViewStateCopyWithImpl<$Res>
implements $RemoteConnectionViewStateCopyWith<$Res> {
_$RemoteConnectionViewStateCopyWithImpl(this._self, this._then);
final RemoteConnectionViewState _self;
final $Res Function(RemoteConnectionViewState) _then;
/// Create a copy of RemoteConnectionViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? deviceId = null,Object? isoCode = null,Object? phone = null,Object? pictures = null,Object? pictureIndex = null,Object? isLoadingPictures = null,Object? isTakingPicture = null,Object? isWaitingForPhoto = null,Object? photoCountdown = null,Object? isCalling = null,Object? errorEvent = freezed,Object? successEvent = freezed,}) {
return _then(_self.copyWith(
deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable
as String,phone: null == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
as String,pictures: null == pictures ? _self.pictures : pictures // ignore: cast_nullable_to_non_nullable
as List<PictureEntity>,pictureIndex: null == pictureIndex ? _self.pictureIndex : pictureIndex // ignore: cast_nullable_to_non_nullable
as int,isLoadingPictures: null == isLoadingPictures ? _self.isLoadingPictures : isLoadingPictures // ignore: cast_nullable_to_non_nullable
as bool,isTakingPicture: null == isTakingPicture ? _self.isTakingPicture : isTakingPicture // ignore: cast_nullable_to_non_nullable
as bool,isWaitingForPhoto: null == isWaitingForPhoto ? _self.isWaitingForPhoto : isWaitingForPhoto // ignore: cast_nullable_to_non_nullable
as bool,photoCountdown: null == photoCountdown ? _self.photoCountdown : photoCountdown // ignore: cast_nullable_to_non_nullable
as int,isCalling: null == isCalling ? _self.isCalling : isCalling // ignore: cast_nullable_to_non_nullable
as bool,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as RemoteConnectionErrorEvent?,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as RemoteConnectionSuccessEvent?,
));
}
}
/// Adds pattern-matching-related methods to [RemoteConnectionViewState].
extension RemoteConnectionViewStatePatterns on RemoteConnectionViewState {
/// 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( _RemoteConnectionViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RemoteConnectionViewState() 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( _RemoteConnectionViewState value) $default,){
final _that = this;
switch (_that) {
case _RemoteConnectionViewState():
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( _RemoteConnectionViewState value)? $default,){
final _that = this;
switch (_that) {
case _RemoteConnectionViewState() 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 deviceId, String isoCode, String phone, List<PictureEntity> pictures, int pictureIndex, bool isLoadingPictures, bool isTakingPicture, bool isWaitingForPhoto, int photoCountdown, bool isCalling, RemoteConnectionErrorEvent? errorEvent, RemoteConnectionSuccessEvent? successEvent)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RemoteConnectionViewState() when $default != null:
return $default(_that.deviceId,_that.isoCode,_that.phone,_that.pictures,_that.pictureIndex,_that.isLoadingPictures,_that.isTakingPicture,_that.isWaitingForPhoto,_that.photoCountdown,_that.isCalling,_that.errorEvent,_that.successEvent);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 deviceId, String isoCode, String phone, List<PictureEntity> pictures, int pictureIndex, bool isLoadingPictures, bool isTakingPicture, bool isWaitingForPhoto, int photoCountdown, bool isCalling, RemoteConnectionErrorEvent? errorEvent, RemoteConnectionSuccessEvent? successEvent) $default,) {final _that = this;
switch (_that) {
case _RemoteConnectionViewState():
return $default(_that.deviceId,_that.isoCode,_that.phone,_that.pictures,_that.pictureIndex,_that.isLoadingPictures,_that.isTakingPicture,_that.isWaitingForPhoto,_that.photoCountdown,_that.isCalling,_that.errorEvent,_that.successEvent);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 deviceId, String isoCode, String phone, List<PictureEntity> pictures, int pictureIndex, bool isLoadingPictures, bool isTakingPicture, bool isWaitingForPhoto, int photoCountdown, bool isCalling, RemoteConnectionErrorEvent? errorEvent, RemoteConnectionSuccessEvent? successEvent)? $default,) {final _that = this;
switch (_that) {
case _RemoteConnectionViewState() when $default != null:
return $default(_that.deviceId,_that.isoCode,_that.phone,_that.pictures,_that.pictureIndex,_that.isLoadingPictures,_that.isTakingPicture,_that.isWaitingForPhoto,_that.photoCountdown,_that.isCalling,_that.errorEvent,_that.successEvent);case _:
return null;
}
}
}
/// @nodoc
class _RemoteConnectionViewState implements RemoteConnectionViewState {
const _RemoteConnectionViewState({this.deviceId = '', this.isoCode = 'ES', this.phone = '', final List<PictureEntity> pictures = const [], this.pictureIndex = 0, this.isLoadingPictures = true, this.isTakingPicture = false, this.isWaitingForPhoto = false, this.photoCountdown = 0, this.isCalling = false, this.errorEvent, this.successEvent}): _pictures = pictures;
@override@JsonKey() final String deviceId;
@override@JsonKey() final String isoCode;
@override@JsonKey() final String phone;
final List<PictureEntity> _pictures;
@override@JsonKey() List<PictureEntity> get pictures {
if (_pictures is EqualUnmodifiableListView) return _pictures;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_pictures);
}
@override@JsonKey() final int pictureIndex;
@override@JsonKey() final bool isLoadingPictures;
@override@JsonKey() final bool isTakingPicture;
@override@JsonKey() final bool isWaitingForPhoto;
@override@JsonKey() final int photoCountdown;
@override@JsonKey() final bool isCalling;
@override final RemoteConnectionErrorEvent? errorEvent;
@override final RemoteConnectionSuccessEvent? successEvent;
/// Create a copy of RemoteConnectionViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RemoteConnectionViewStateCopyWith<_RemoteConnectionViewState> get copyWith => __$RemoteConnectionViewStateCopyWithImpl<_RemoteConnectionViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RemoteConnectionViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.isoCode, isoCode) || other.isoCode == isoCode)&&(identical(other.phone, phone) || other.phone == phone)&&const DeepCollectionEquality().equals(other._pictures, _pictures)&&(identical(other.pictureIndex, pictureIndex) || other.pictureIndex == pictureIndex)&&(identical(other.isLoadingPictures, isLoadingPictures) || other.isLoadingPictures == isLoadingPictures)&&(identical(other.isTakingPicture, isTakingPicture) || other.isTakingPicture == isTakingPicture)&&(identical(other.isWaitingForPhoto, isWaitingForPhoto) || other.isWaitingForPhoto == isWaitingForPhoto)&&(identical(other.photoCountdown, photoCountdown) || other.photoCountdown == photoCountdown)&&(identical(other.isCalling, isCalling) || other.isCalling == isCalling)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent));
}
@override
int get hashCode => Object.hash(runtimeType,deviceId,isoCode,phone,const DeepCollectionEquality().hash(_pictures),pictureIndex,isLoadingPictures,isTakingPicture,isWaitingForPhoto,photoCountdown,isCalling,errorEvent,successEvent);
@override
String toString() {
return 'RemoteConnectionViewState(deviceId: $deviceId, isoCode: $isoCode, phone: $phone, pictures: $pictures, pictureIndex: $pictureIndex, isLoadingPictures: $isLoadingPictures, isTakingPicture: $isTakingPicture, isWaitingForPhoto: $isWaitingForPhoto, photoCountdown: $photoCountdown, isCalling: $isCalling, errorEvent: $errorEvent, successEvent: $successEvent)';
}
}
/// @nodoc
abstract mixin class _$RemoteConnectionViewStateCopyWith<$Res> implements $RemoteConnectionViewStateCopyWith<$Res> {
factory _$RemoteConnectionViewStateCopyWith(_RemoteConnectionViewState value, $Res Function(_RemoteConnectionViewState) _then) = __$RemoteConnectionViewStateCopyWithImpl;
@override @useResult
$Res call({
String deviceId, String isoCode, String phone, List<PictureEntity> pictures, int pictureIndex, bool isLoadingPictures, bool isTakingPicture, bool isWaitingForPhoto, int photoCountdown, bool isCalling, RemoteConnectionErrorEvent? errorEvent, RemoteConnectionSuccessEvent? successEvent
});
}
/// @nodoc
class __$RemoteConnectionViewStateCopyWithImpl<$Res>
implements _$RemoteConnectionViewStateCopyWith<$Res> {
__$RemoteConnectionViewStateCopyWithImpl(this._self, this._then);
final _RemoteConnectionViewState _self;
final $Res Function(_RemoteConnectionViewState) _then;
/// Create a copy of RemoteConnectionViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? deviceId = null,Object? isoCode = null,Object? phone = null,Object? pictures = null,Object? pictureIndex = null,Object? isLoadingPictures = null,Object? isTakingPicture = null,Object? isWaitingForPhoto = null,Object? photoCountdown = null,Object? isCalling = null,Object? errorEvent = freezed,Object? successEvent = freezed,}) {
return _then(_RemoteConnectionViewState(
deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,isoCode: null == isoCode ? _self.isoCode : isoCode // ignore: cast_nullable_to_non_nullable
as String,phone: null == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
as String,pictures: null == pictures ? _self._pictures : pictures // ignore: cast_nullable_to_non_nullable
as List<PictureEntity>,pictureIndex: null == pictureIndex ? _self.pictureIndex : pictureIndex // ignore: cast_nullable_to_non_nullable
as int,isLoadingPictures: null == isLoadingPictures ? _self.isLoadingPictures : isLoadingPictures // ignore: cast_nullable_to_non_nullable
as bool,isTakingPicture: null == isTakingPicture ? _self.isTakingPicture : isTakingPicture // ignore: cast_nullable_to_non_nullable
as bool,isWaitingForPhoto: null == isWaitingForPhoto ? _self.isWaitingForPhoto : isWaitingForPhoto // ignore: cast_nullable_to_non_nullable
as bool,photoCountdown: null == photoCountdown ? _self.photoCountdown : photoCountdown // ignore: cast_nullable_to_non_nullable
as int,isCalling: null == isCalling ? _self.isCalling : isCalling // ignore: cast_nullable_to_non_nullable
as bool,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as RemoteConnectionErrorEvent?,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as RemoteConnectionSuccessEvent?,
));
}
}
// dart format on

View File

@@ -1,8 +1,10 @@
import 'package:device_management/src/features/remote_connection/domain/entities/picture_entity.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_picture_index_provider.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/remote_pictures_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:device_management/src/features/remote_connection/domain/entities/picture_entity.dart';
import 'package:device_management/src/features/remote_connection/presentation/state/remote_connection_view_model.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
class ShowPictureDialog extends ConsumerWidget {
@@ -10,15 +12,15 @@ class ShowPictureDialog extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.read(remoteConnectionViewModelProvider.notifier);
final pictures = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.pictures),
);
final pictureIndex = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.pictureIndex),
);
final device = ref.watch(selectedDeviceProvider).value;
if (device == null) return const SizedBox.shrink();
if (pictures.isEmpty) {
final picturesAsync =
ref.watch(remotePicturesProvider(device.identificator));
final pictureIndex = ref.watch(remotePictureIndexProvider);
final pictures = picturesAsync.value ?? const [];
if (pictures.isEmpty || pictureIndex >= pictures.length) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
@@ -47,8 +49,12 @@ class ShowPictureDialog extends ConsumerWidget {
Expanded(child: _PictureSection(picture: picture)),
_MetadataSection(picture: picture),
_ControlsSection(
prev: viewModel.prevPicture,
next: viewModel.nextPicture,
prev: () => ref
.read(remotePictureIndexProvider.notifier)
.prev(total: pictures.length),
next: () => ref
.read(remotePictureIndexProvider.notifier)
.next(total: pictures.length),
),
],
),
@@ -90,7 +96,10 @@ class _MetadataSection extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
dateStr,
style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurfaceVariant),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}

View File

@@ -1,57 +1,67 @@
import 'package:design_system/design_system.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/spy_call_controller.dart';
import 'package:device_management/src/features/remote_connection/presentation/providers/spy_call_form_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:device_management/src/features/remote_connection/presentation/state/remote_connection_view_model.dart';
import 'package:device_management/src/features/remote_connection/presentation/state/remote_connection_view_state.dart';
import 'package:legacy_device_state/legacy_device_state.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';
class SpyCallDialog extends ConsumerWidget {
class SpyCallDialog extends ConsumerStatefulWidget {
const SpyCallDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(remoteConnectionViewModelProvider.notifier);
ConsumerState<SpyCallDialog> createState() => _SpyCallDialogState();
}
ref.listen(remoteConnectionViewModelProvider.select((s) => s.errorEvent), (
_,
next,
) {
if (next != null) {
final message = switch (next) {
RemoteConnectionErrorEvent.invalidPhone => context.translate(
I18n.errorMessagePhoneIsInvalid,
),
RemoteConnectionErrorEvent.call => context.translate(I18n.errorCall),
RemoteConnectionErrorEvent.takePicture => context.translate(
I18n.errorTakePicture,
),
RemoteConnectionErrorEvent.fetchPhotos => context.translate(
I18n.errorFetchPhotos,
),
};
showTopSnackbar(context, message: message, type: MessageType.error);
}
});
class _SpyCallDialogState extends ConsumerState<SpyCallDialog> {
final _phoneController = TextEditingController();
ref.listen(remoteConnectionViewModelProvider.select((s) => s.isCalling), (
prev,
isCalling,
) {
if (prev == true && !isCalling) {
final error = ref.read(remoteConnectionViewModelProvider).errorEvent;
if (error == null && context.mounted) {
Navigator.pop(context);
showTopSnackbar(
context,
message: context.translate(I18n.remoteListening),
type: MessageType.success,
);
}
}
});
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
final isoCode = ref.read(spyCallFormProvider);
final parsed = SfPhoneNumber.tryParse(
_phoneController.text,
defaultIsoCode: isoCode,
);
if (parsed == null) {
await showErrorDialog(context, I18n.errorMessagePhoneIsInvalid);
return;
}
final device = ref.read(selectedDeviceProvider).value;
if (device == null) return;
await ref.read(spyCallControllerProvider.notifier).call(
deviceIdentificator: device.identificator,
phoneE164: parsed.e164,
);
if (!mounted) return;
final state = ref.read(spyCallControllerProvider);
if (state.hasError) {
await showErrorDialog(context, I18n.errorCall);
return;
}
Navigator.pop(context);
await showSuccessDialog(context, I18n.remoteListening);
}
@override
Widget build(BuildContext context) {
final primaryColor = context.sfColors.legacyPrimary;
final isoCode = ref.watch(spyCallFormProvider);
final isCalling =
ref.watch(spyCallControllerProvider.select((s) => s.isLoading));
return Container(
padding: SizeUtils.getByScreen(
@@ -66,7 +76,26 @@ class SpyCallDialog extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _Header(),
Stack(
children: [
Center(
child: Text(
context.translate(I18n.remoteListening),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 19, big: 18),
),
),
),
Align(
alignment: Alignment.centerRight,
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.close, color: primaryColor),
),
),
],
),
SizedBox(height: SizeUtils.getByScreen(small: 8, big: 7)),
Text(
context.translate(I18n.spyCallSubtitle),
@@ -77,106 +106,43 @@ class SpyCallDialog extends ConsumerWidget {
),
),
SizedBox(height: SizeUtils.getByScreen(small: 12, big: 10)),
_PhoneSection(onSubmit: () async {
if (!await guardDeviceCommand(context, ref)) return;
vm.call();
}),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CountryPrefixPicker(
headerText: context.translate(I18n.selectYourCountry),
initialSelection: isoCode,
onChanged: (country) {
final code = country.code;
if (code != null) {
ref.read(spyCallFormProvider.notifier).setIsoCode(code);
}
},
width: 80,
),
SizedBox(width: SizeUtils.getByScreen(small: 8, big: 7)),
Expanded(
child: CustomTextField(
controller: _phoneController,
hint: context.translate(I18n.insertPhone),
keyboardType: TextInputType.phone,
onSubmitted: (_) => _submit(),
),
),
],
),
SizedBox(height: SizeUtils.getByScreen(small: 28, big: 27)),
_CallSection(onPressed: () async {
if (!await guardDeviceCommand(context, ref)) return;
vm.call();
}),
isCalling
? const Center(child: CircularProgressIndicator())
: PrimaryButton(
onPressed: _submit,
text: context.translate(I18n.call),
color: primaryColor,
height: SizeUtils.getByScreen(small: 38, big: 36),
radius: SizeUtils.getByScreen(small: 32, big: 34),
),
],
),
);
}
}
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) {
return Stack(
children: [
Center(
child: Text(
context.translate(I18n.remoteListening),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 19, big: 18),
),
),
),
Align(
alignment: Alignment.centerRight,
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.close, color: context.sfColors.legacyPrimary),
),
),
],
);
}
}
class _PhoneSection extends ConsumerWidget {
final VoidCallback onSubmit;
const _PhoneSection({required this.onSubmit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(remoteConnectionViewModelProvider.notifier);
final isoCode = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.isoCode),
);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CountryPrefixPicker(
headerText: context.translate(I18n.selectYourCountry),
initialSelection: isoCode,
onChanged: (country) {
final code = country.code;
if (code != null) vm.updateCountry(code);
},
width: 80,
),
SizedBox(width: SizeUtils.getByScreen(small: 8, big: 7)),
Expanded(
child: CustomTextField(
controller: vm.phoneController,
hint: context.translate(I18n.insertPhone),
keyboardType: TextInputType.phone,
onSubmitted: (_) => onSubmit(),
),
),
],
);
}
}
class _CallSection extends ConsumerWidget {
final VoidCallback onPressed;
const _CallSection({required this.onPressed});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCalling = ref.watch(
remoteConnectionViewModelProvider.select((s) => s.isCalling),
);
return isCalling
? const Center(child: CircularProgressIndicator())
: PrimaryButton(
onPressed: onPressed,
text: context.translate(I18n.call),
color: context.sfColors.legacyPrimary,
height: SizeUtils.getByScreen(small: 38, big: 36),
radius: SizeUtils.getByScreen(small: 32, big: 34),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:device_management/src/features/remote_connection/presentation/providers/spy_call_controller.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:legacy_device_state/legacy_device_state.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 MockCommandsRepository extends Mock implements CommandsRepository {}
void main() {
setUpAll(() {
registerFallbackValue(
SendCommandRequestModel(
device: 'x',
command: DeviceCommand.callCenter,
),
);
});
ProviderContainer buildContainer(CommandsRepository commands) {
return makeContainer(
overrides: [
commandsRepositoryProvider.overrideWithValue(commands),
sfTrackingProvider.overrideWithValue(
SfTrackingRepository(clients: const []),
),
],
);
}
group('SpyCallController.call', () {
test('sends CALL_CENTER command and transitions to AsyncData', () async {
final commands = MockCommandsRepository();
when(() => commands.send(request: any(named: 'request')))
.thenAnswer((_) async {});
final container = buildContainer(commands);
addTearDown(container.dispose);
await container.read(spyCallControllerProvider.notifier).call(
deviceIdentificator: 'imei-1',
phoneE164: '+34600000001',
);
expect(
container.read(spyCallControllerProvider),
isA<AsyncData<void>>(),
);
final sent = verify(
() => commands.send(request: captureAny(named: 'request')),
).captured.single as SendCommandRequestModel;
expect(sent.command, DeviceCommand.callCenter);
expect(sent.device, 'imei-1');
expect(sent.data, {'phone_number': '+34600000001'});
});
test('exposes AsyncError when command fails', () async {
final commands = MockCommandsRepository();
when(() => commands.send(request: any(named: 'request')))
.thenThrow(const ApiException(message: 'boom', isNetworkError: true));
final container = buildContainer(commands);
addTearDown(container.dispose);
await container.read(spyCallControllerProvider.notifier).call(
deviceIdentificator: 'imei-1',
phoneE164: '+34600000001',
);
expect(
container.read(spyCallControllerProvider),
isA<AsyncError<void>>(),
);
});
});
}