From 2b9b6aa2153496de96b1b60d30aa446af7d5d00d Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Wed, 22 Apr 2026 00:42:33 +0200 Subject: [PATCH] refactor(legacy-settings): migrate timezone, sound, sync_clock to AsyncNotifier --- .../providers/sound_controller.dart | 33 ++ .../providers/sound_controller.g.dart | 55 ++++ .../providers/sound_selection_provider.dart | 11 + .../providers/sound_selection_provider.g.dart | 63 ++++ .../sound/presentation/sound_screen.dart | 124 ++++--- .../presentation/state/sound_view_model.dart | 81 ----- .../presentation/state/sound_view_state.dart | 15 - .../state/sound_view_state.freezed.dart | 307 ------------------ .../providers/sync_clock_controller.dart | 21 ++ .../providers/sync_clock_controller.g.dart | 56 ++++ .../state/sync_clock_view_model.dart | 46 --- .../state/sync_clock_view_state.dart | 12 - .../state/sync_clock_view_state.freezed.dart | 277 ---------------- .../presentation/sync_clock_screen.dart | 156 ++------- .../providers/timezone_controller.dart | 35 ++ .../providers/timezone_controller.g.dart | 56 ++++ .../timezone_selection_provider.dart | 11 + .../timezone_selection_provider.g.dart | 63 ++++ .../state/timezone_view_model.dart | 80 ----- .../state/timezone_view_state.dart | 16 - .../state/timezone_view_state.freezed.dart | 283 ---------------- .../presentation/timezone_screen.dart | 151 ++++----- .../features/sound/sound_controller_test.dart | 96 ++++++ .../timezone/timezone_controller_test.dart | 96 ++++++ 24 files changed, 748 insertions(+), 1396 deletions(-) create mode 100644 modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.g.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.g.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.freezed.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.g.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.freezed.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.g.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.freezed.dart create mode 100644 modules/legacy/modules/settings/test/features/sound/sound_controller_test.dart create mode 100644 modules/legacy/modules/settings/test/features/timezone/timezone_controller_test.dart diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.dart new file mode 100644 index 00000000..9efad633 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:legacy_device_state/legacy_device_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +part 'sound_controller.g.dart'; + +@riverpod +class SoundController extends _$SoundController { + @override + FutureOr build() {} + + Future save({ + required DeviceEntity device, + required String newMode, + }) async { + if (newMode == device.settings.soundMode) return; + + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final updated = device.settings.copyWith(soundMode: newMode); + await ref + .read(deviceSettingsUpdateProvider) + .updateDeviceSettings(device: device, updatedSettings: updated); + ref.syncDeviceSettings(device, updated); + unawaited( + ref.read(sfTrackingProvider).legacySettingsSoundChanged(mode: newMode), + ); + }); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.g.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.g.dart new file mode 100644 index 00000000..799c472b --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_controller.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sound_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SoundController) +const soundControllerProvider = SoundControllerProvider._(); + +final class SoundControllerProvider + extends $AsyncNotifierProvider { + const SoundControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'soundControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$soundControllerHash(); + + @$internal + @override + SoundController create() => SoundController(); +} + +String _$soundControllerHash() => r'2e33ae828acc061f6ed4bf30de1fc3b0f755c2ba'; + +abstract class _$SoundController extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + build(); + final ref = this.ref as $Ref, void>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, void>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, null); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.dart new file mode 100644 index 00000000..9a8d46bb --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.dart @@ -0,0 +1,11 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'sound_selection_provider.g.dart'; + +@riverpod +class SoundSelection extends _$SoundSelection { + @override + String? build() => null; + + void select(String value) => state = value; +} diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.g.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.g.dart new file mode 100644 index 00000000..43ee8888 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sound/presentation/providers/sound_selection_provider.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sound_selection_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SoundSelection) +const soundSelectionProvider = SoundSelectionProvider._(); + +final class SoundSelectionProvider + extends $NotifierProvider { + const SoundSelectionProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'soundSelectionProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$soundSelectionHash(); + + @$internal + @override + SoundSelection create() => SoundSelection(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$soundSelectionHash() => r'b5ba9a381457a1b10d581dedcf506a1935b47b11'; + +abstract class _$SoundSelection extends $Notifier { + String? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + String?, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/sound_screen.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/sound_screen.dart index b0981444..1d6628a3 100644 --- a/modules/legacy/modules/settings/lib/src/features/sound/presentation/sound_screen.dart +++ b/modules/legacy/modules/settings/lib/src/features/sound/presentation/sound_screen.dart @@ -1,13 +1,14 @@ import 'package:design_system/design_system.dart'; -import 'package:legacy_theme/legacy_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_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:settings/src/features/sound/presentation/providers/sound_controller.dart'; +import 'package:settings/src/features/sound/presentation/providers/sound_selection_provider.dart'; import 'package:sf_localizations/sf_localizations.dart'; - -import 'state/sound_view_model.dart'; +import 'package:sf_shared/sf_shared.dart'; class SoundScreen extends ConsumerWidget { final NavigationContract navigationContract; @@ -16,26 +17,20 @@ class SoundScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - - ref.listen(soundViewModelProvider.select((s) => s.errorMessage), ( - _, - errorMessage, - ) { - if (errorMessage.isNotEmpty) { - showTopSnackbar( - context, - message: errorMessage, - type: MessageType.error, - ); + ref.listen(soundControllerProvider, (prev, next) async { + next.showErrorOn(context); + if (prev != null && + prev.isLoading && + !next.isLoading && + !next.hasError) { + await showSuccessDialog(context, I18n.deviceUpdatedSuccess); + if (context.mounted) navigationContract.goBack(); } }); - ref.listen(soundViewModelProvider.select((s) => s.isComplete), ( - _, - isComplete, - ) { - if (isComplete) Navigator.pop(context); - }); + final device = ref.watch(selectedDeviceProvider).value; + final currentMode = device?.settings.soundMode ?? ''; + final selectedMode = ref.watch(soundSelectionProvider) ?? currentMode; return LegacyPageLayout( title: context.translate(I18n.sound), @@ -51,25 +46,23 @@ class SoundScreen extends ConsumerWidget { ), ), const SizedBox(height: 36), - const _OptionsSection(), + _OptionsSection(selectedMode: selectedMode), ], ), ), - footer: const _SaveSection(), + footer: _SaveSection(device: device, selectedMode: selectedMode), ); } } class _OptionsSection extends ConsumerWidget { - const _OptionsSection(); + final String selectedMode; + + const _OptionsSection({required this.selectedMode}); @override Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(soundViewModelProvider.notifier); - - final soundOption = ref.watch( - soundViewModelProvider.select((s) => s.soundOption), - ); + final select = ref.read(soundSelectionProvider.notifier).select; return SingleChildScrollView( child: Column( @@ -77,37 +70,29 @@ class _OptionsSection extends ConsumerWidget { _SectionButton( title: context.translate(I18n.soundAndVibration), icon: Icons.volume_up_outlined, - active: soundOption == 'VIBRATION_AND_RINGING', - onPressed: () { - vm.setSoundOption('VIBRATION_AND_RINGING'); - }, + active: selectedMode == 'VIBRATION_AND_RINGING', + onPressed: () => select('VIBRATION_AND_RINGING'), ), - SizedBox(height: 12), + const SizedBox(height: 12), _SectionButton( title: context.translate(I18n.soundOnly), icon: Icons.volume_up_outlined, - active: soundOption == 'RINGING', - onPressed: () { - vm.setSoundOption('RINGING'); - }, + active: selectedMode == 'RINGING', + onPressed: () => select('RINGING'), ), - SizedBox(height: 12), + const SizedBox(height: 12), _SectionButton( title: context.translate(I18n.vibrationOnly), icon: Icons.vibration_outlined, - active: soundOption == 'VIBRATION', - onPressed: () { - vm.setSoundOption('VIBRATION'); - }, + active: selectedMode == 'VIBRATION', + onPressed: () => select('VIBRATION'), ), - SizedBox(height: 12), + const SizedBox(height: 12), _SectionButton( title: context.translate(I18n.silent), icon: Icons.volume_mute_outlined, - active: soundOption == 'SILENCE', - onPressed: () { - vm.setSoundOption('SILENCE'); - }, + active: selectedMode == 'SILENCE', + onPressed: () => select('SILENCE'), ), ], ), @@ -115,7 +100,7 @@ class _OptionsSection extends ConsumerWidget { } } -class _SectionButton extends ConsumerWidget { +class _SectionButton extends StatelessWidget { final String title; final bool active; final IconData icon; @@ -129,15 +114,10 @@ class _SectionButton extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { - + Widget build(BuildContext context) { return SectionButton( onPressed: onPressed, - icon: Icon( - icon, - color: context.sfColors.legacyPrimary, - size: 36, - ), + icon: Icon(icon, color: context.sfColors.legacyPrimary, size: 36), iconPadding: 8, body: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -152,12 +132,7 @@ class _SectionButton extends ConsumerWidget { : Theme.of(context).colorScheme.outline, ), ), - Switch( - value: active, - onChanged: (_) { - onPressed(); - }, - ), + Switch(value: active, onChanged: (_) => onPressed()), ], ), ); @@ -165,20 +140,31 @@ class _SectionButton extends ConsumerWidget { } class _SaveSection extends ConsumerWidget { - const _SaveSection(); + final DeviceEntity? device; + final String selectedMode; + + const _SaveSection({required this.device, required this.selectedMode}); @override Widget build(BuildContext context, WidgetRef ref) { - - final vm = ref.read(soundViewModelProvider.notifier); + final isLoading = ref.watch( + soundControllerProvider.select((s) => s.isLoading), + ); return Padding( - padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), child: PrimaryButton( - onPressed: () async { - if (!await guardDeviceCommand(context, ref)) return; - vm.submit(); - }, + onPressed: isLoading + ? null + : () async { + if (device == null) return; + if (!await guardDeviceCommand(context, ref)) return; + if (!context.mounted) return; + ref.read(soundControllerProvider.notifier).save( + device: device!, + newMode: selectedMode, + ); + }, text: context.translate(I18n.save), color: context.sfColors.legacyPrimary, ), diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart deleted file mode 100644 index 6d2a8038..00000000 --- a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.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 'sound_view_state.dart'; - -final soundViewModelProvider = - NotifierProvider.autoDispose( - SoundViewModel.new, - ); - -class SoundViewModel extends Notifier { - late final DeviceSettingsUpdateDatasource _datasource; - late final SfTrackingRepository _tracking; - - @override - SoundViewState build() { - _datasource = ref.read(deviceSettingsUpdateProvider); - _tracking = ref.read(sfTrackingProvider); - Future.microtask(() => load()); - return const SoundViewState(); - } - - Future load() async { - final device = ref.read(selectedDeviceProvider).value; - if (device == null) return; - - state = state.copyWith( - device: device, - soundOption: device.settings.soundMode, - isLoading: false, - ); - } - - void setSoundOption(String value) { - if (state.soundOption == value) return; - state = state.copyWith(soundOption: value); - } - - Future submit() async { - final device = state.device; - if (device == null) return; - - if (state.soundOption == device.settings.soundMode) { - state = state.copyWith(isComplete: true); - return; - } - - try { - state = state.copyWith( - isLoading: true, - isComplete: false, - errorMessage: '', - ); - - final updatedSettings = device.settings.copyWith( - soundMode: state.soundOption, - ); - - await _datasource.updateDeviceSettings( - device: device, - updatedSettings: updatedSettings, - ); - - if (!ref.mounted) return; - ref.syncDeviceSettings(device, updatedSettings); - - unawaited( - _tracking.legacySettingsSoundChanged(mode: state.soundOption ?? ''), - ); - - state = state.copyWith(isLoading: false, isComplete: true); - } catch (e) { - if (!ref.mounted) return; - state = state.copyWith(isLoading: false, errorMessage: e.toString()); - } - } -} diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.dart deleted file mode 100644 index afdb8b66..00000000 --- a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:sf_shared/sf_shared.dart'; - -part 'sound_view_state.freezed.dart'; - -@freezed -abstract class SoundViewState with _$SoundViewState { - const factory SoundViewState({ - DeviceEntity? device, - String? soundOption, - @Default(true) bool isLoading, - @Default(false) bool isComplete, - @Default('') String errorMessage, - }) = _SoundViewState; -} diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.freezed.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.freezed.dart deleted file mode 100644 index 5bd68301..00000000 --- a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_state.freezed.dart +++ /dev/null @@ -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 'sound_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$SoundViewState { - - DeviceEntity? get device; String? get soundOption; bool get isLoading; bool get isComplete; String get errorMessage; -/// Create a copy of SoundViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$SoundViewStateCopyWith get copyWith => _$SoundViewStateCopyWithImpl(this as SoundViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SoundViewState&&(identical(other.device, device) || other.device == device)&&(identical(other.soundOption, soundOption) || other.soundOption == soundOption)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,device,soundOption,isLoading,isComplete,errorMessage); - -@override -String toString() { - return 'SoundViewState(device: $device, soundOption: $soundOption, isLoading: $isLoading, isComplete: $isComplete, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class $SoundViewStateCopyWith<$Res> { - factory $SoundViewStateCopyWith(SoundViewState value, $Res Function(SoundViewState) _then) = _$SoundViewStateCopyWithImpl; -@useResult -$Res call({ - DeviceEntity? device, String? soundOption, bool isLoading, bool isComplete, String errorMessage -}); - - -$DeviceEntityCopyWith<$Res>? get device; - -} -/// @nodoc -class _$SoundViewStateCopyWithImpl<$Res> - implements $SoundViewStateCopyWith<$Res> { - _$SoundViewStateCopyWithImpl(this._self, this._then); - - final SoundViewState _self; - final $Res Function(SoundViewState) _then; - -/// Create a copy of SoundViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? device = freezed,Object? soundOption = freezed,Object? isLoading = null,Object? isComplete = null,Object? errorMessage = null,}) { - return _then(_self.copyWith( -device: freezed == device ? _self.device : device // ignore: cast_nullable_to_non_nullable -as DeviceEntity?,soundOption: freezed == soundOption ? _self.soundOption : soundOption // ignore: cast_nullable_to_non_nullable -as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable -as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String, - )); -} -/// Create a copy of SoundViewState -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$DeviceEntityCopyWith<$Res>? get device { - if (_self.device == null) { - return null; - } - - return $DeviceEntityCopyWith<$Res>(_self.device!, (value) { - return _then(_self.copyWith(device: value)); - }); -} -} - - -/// Adds pattern-matching-related methods to [SoundViewState]. -extension SoundViewStatePatterns on SoundViewState { -/// 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 Function( _SoundViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _SoundViewState() 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 Function( _SoundViewState value) $default,){ -final _that = this; -switch (_that) { -case _SoundViewState(): -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? Function( _SoundViewState value)? $default,){ -final _that = this; -switch (_that) { -case _SoundViewState() 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 Function( DeviceEntity? device, String? soundOption, bool isLoading, bool isComplete, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _SoundViewState() when $default != null: -return $default(_that.device,_that.soundOption,_that.isLoading,_that.isComplete,_that.errorMessage);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// As opposed to `map`, this offers destructuring. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case Subclass2(:final field2): -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult when(TResult Function( DeviceEntity? device, String? soundOption, bool isLoading, bool isComplete, String errorMessage) $default,) {final _that = this; -switch (_that) { -case _SoundViewState(): -return $default(_that.device,_that.soundOption,_that.isLoading,_that.isComplete,_that.errorMessage);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `when` that fallback to returning `null` -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? whenOrNull(TResult? Function( DeviceEntity? device, String? soundOption, bool isLoading, bool isComplete, String errorMessage)? $default,) {final _that = this; -switch (_that) { -case _SoundViewState() when $default != null: -return $default(_that.device,_that.soundOption,_that.isLoading,_that.isComplete,_that.errorMessage);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _SoundViewState implements SoundViewState { - const _SoundViewState({this.device, this.soundOption, this.isLoading = true, this.isComplete = false, this.errorMessage = ''}); - - -@override final DeviceEntity? device; -@override final String? soundOption; -@override@JsonKey() final bool isLoading; -@override@JsonKey() final bool isComplete; -@override@JsonKey() final String errorMessage; - -/// Create a copy of SoundViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$SoundViewStateCopyWith<_SoundViewState> get copyWith => __$SoundViewStateCopyWithImpl<_SoundViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SoundViewState&&(identical(other.device, device) || other.device == device)&&(identical(other.soundOption, soundOption) || other.soundOption == soundOption)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,device,soundOption,isLoading,isComplete,errorMessage); - -@override -String toString() { - return 'SoundViewState(device: $device, soundOption: $soundOption, isLoading: $isLoading, isComplete: $isComplete, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class _$SoundViewStateCopyWith<$Res> implements $SoundViewStateCopyWith<$Res> { - factory _$SoundViewStateCopyWith(_SoundViewState value, $Res Function(_SoundViewState) _then) = __$SoundViewStateCopyWithImpl; -@override @useResult -$Res call({ - DeviceEntity? device, String? soundOption, bool isLoading, bool isComplete, String errorMessage -}); - - -@override $DeviceEntityCopyWith<$Res>? get device; - -} -/// @nodoc -class __$SoundViewStateCopyWithImpl<$Res> - implements _$SoundViewStateCopyWith<$Res> { - __$SoundViewStateCopyWithImpl(this._self, this._then); - - final _SoundViewState _self; - final $Res Function(_SoundViewState) _then; - -/// Create a copy of SoundViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? device = freezed,Object? soundOption = freezed,Object? isLoading = null,Object? isComplete = null,Object? errorMessage = null,}) { - return _then(_SoundViewState( -device: freezed == device ? _self.device : device // ignore: cast_nullable_to_non_nullable -as DeviceEntity?,soundOption: freezed == soundOption ? _self.soundOption : soundOption // ignore: cast_nullable_to_non_nullable -as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable -as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String, - )); -} - -/// Create a copy of SoundViewState -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$DeviceEntityCopyWith<$Res>? get device { - if (_self.device == null) { - return null; - } - - return $DeviceEntityCopyWith<$Res>(_self.device!, (value) { - return _then(_self.copyWith(device: value)); - }); -} -} - -// dart format on diff --git a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.dart b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.dart new file mode 100644 index 00000000..cc02536c --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +part 'sync_clock_controller.g.dart'; + +@riverpod +class SyncClockController extends _$SyncClockController { + @override + FutureOr build() {} + + Future sync() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + unawaited( + ref.read(sfTrackingProvider).legacySettingsSyncClockTriggered(), + ); + }); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.g.dart b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.g.dart new file mode 100644 index 00000000..aebf8797 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/providers/sync_clock_controller.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_clock_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SyncClockController) +const syncClockControllerProvider = SyncClockControllerProvider._(); + +final class SyncClockControllerProvider + extends $AsyncNotifierProvider { + const SyncClockControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncClockControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncClockControllerHash(); + + @$internal + @override + SyncClockController create() => SyncClockController(); +} + +String _$syncClockControllerHash() => + r'4969dcad124bfea5ea7d704951b972b914dd31db'; + +abstract class _$SyncClockController extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + build(); + final ref = this.ref as $Ref, void>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, void>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, null); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart deleted file mode 100644 index 1e0625d7..00000000 --- a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart +++ /dev/null @@ -1,46 +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 'sync_clock_view_state.dart'; - -final syncClockViewModelProvider = - NotifierProvider.autoDispose( - SyncClockViewModel.new, - ); - -class SyncClockViewModel extends Notifier { - late final SfTrackingRepository _tracking; - - @override - SyncClockViewState build() { - _tracking = ref.read(sfTrackingProvider); - - Future.microtask(() => load()); - - return const SyncClockViewState(); - } - - Future load() async { - final device = ref.read(selectedDeviceProvider).value; - setDevice(device!); - } - - void setDevice(DeviceEntity device) { - state = state.copyWith(deviceId: device.identificator, isLoading: false); - } - - Future syncClock() async { - if (state.isLoading) return; - - try { - state = state.copyWith(isLoading: true); - // Stub: real call is ref.read(settingsRepositoryProvider).syncClock(deviceId: state.deviceId) - unawaited(_tracking.legacySettingsSyncClockTriggered()); - } catch (e) { - state = state.copyWith(isLoading: false, errorMessage: e.toString()); - } - } -} diff --git a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.dart b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.dart deleted file mode 100644 index 82da6bba..00000000 --- a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'sync_clock_view_state.freezed.dart'; - -@freezed -abstract class SyncClockViewState with _$SyncClockViewState { - const factory SyncClockViewState({ - @Default('') String deviceId, - @Default(true) bool isLoading, - @Default('') String errorMessage, - }) = _SyncClockViewState; -} diff --git a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.freezed.dart b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.freezed.dart deleted file mode 100644 index d0abebe6..00000000 --- a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_state.freezed.dart +++ /dev/null @@ -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 'sync_clock_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$SyncClockViewState { - - String get deviceId; bool get isLoading; String get errorMessage; -/// Create a copy of SyncClockViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$SyncClockViewStateCopyWith get copyWith => _$SyncClockViewStateCopyWithImpl(this as SyncClockViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncClockViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,deviceId,isLoading,errorMessage); - -@override -String toString() { - return 'SyncClockViewState(deviceId: $deviceId, isLoading: $isLoading, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class $SyncClockViewStateCopyWith<$Res> { - factory $SyncClockViewStateCopyWith(SyncClockViewState value, $Res Function(SyncClockViewState) _then) = _$SyncClockViewStateCopyWithImpl; -@useResult -$Res call({ - String deviceId, bool isLoading, String errorMessage -}); - - - - -} -/// @nodoc -class _$SyncClockViewStateCopyWithImpl<$Res> - implements $SyncClockViewStateCopyWith<$Res> { - _$SyncClockViewStateCopyWithImpl(this._self, this._then); - - final SyncClockViewState _self; - final $Res Function(SyncClockViewState) _then; - -/// Create a copy of SyncClockViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? deviceId = null,Object? isLoading = null,Object? errorMessage = null,}) { - return _then(_self.copyWith( -deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable -as String,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String, - )); -} - -} - - -/// Adds pattern-matching-related methods to [SyncClockViewState]. -extension SyncClockViewStatePatterns on SyncClockViewState { -/// 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 Function( _SyncClockViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _SyncClockViewState() 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 Function( _SyncClockViewState value) $default,){ -final _that = this; -switch (_that) { -case _SyncClockViewState(): -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? Function( _SyncClockViewState value)? $default,){ -final _that = this; -switch (_that) { -case _SyncClockViewState() 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 Function( String deviceId, bool isLoading, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _SyncClockViewState() when $default != null: -return $default(_that.deviceId,_that.isLoading,_that.errorMessage);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// As opposed to `map`, this offers destructuring. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case Subclass2(:final field2): -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult when(TResult Function( String deviceId, bool isLoading, String errorMessage) $default,) {final _that = this; -switch (_that) { -case _SyncClockViewState(): -return $default(_that.deviceId,_that.isLoading,_that.errorMessage);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `when` that fallback to returning `null` -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String deviceId, bool isLoading, String errorMessage)? $default,) {final _that = this; -switch (_that) { -case _SyncClockViewState() when $default != null: -return $default(_that.deviceId,_that.isLoading,_that.errorMessage);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _SyncClockViewState implements SyncClockViewState { - const _SyncClockViewState({this.deviceId = '', this.isLoading = true, this.errorMessage = ''}); - - -@override@JsonKey() final String deviceId; -@override@JsonKey() final bool isLoading; -@override@JsonKey() final String errorMessage; - -/// Create a copy of SyncClockViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$SyncClockViewStateCopyWith<_SyncClockViewState> get copyWith => __$SyncClockViewStateCopyWithImpl<_SyncClockViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SyncClockViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,deviceId,isLoading,errorMessage); - -@override -String toString() { - return 'SyncClockViewState(deviceId: $deviceId, isLoading: $isLoading, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class _$SyncClockViewStateCopyWith<$Res> implements $SyncClockViewStateCopyWith<$Res> { - factory _$SyncClockViewStateCopyWith(_SyncClockViewState value, $Res Function(_SyncClockViewState) _then) = __$SyncClockViewStateCopyWithImpl; -@override @useResult -$Res call({ - String deviceId, bool isLoading, String errorMessage -}); - - - - -} -/// @nodoc -class __$SyncClockViewStateCopyWithImpl<$Res> - implements _$SyncClockViewStateCopyWith<$Res> { - __$SyncClockViewStateCopyWithImpl(this._self, this._then); - - final _SyncClockViewState _self; - final $Res Function(_SyncClockViewState) _then; - -/// Create a copy of SyncClockViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? deviceId = null,Object? isLoading = null,Object? errorMessage = null,}) { - return _then(_SyncClockViewState( -deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable -as String,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String, - )); -} - - -} - -// dart format on diff --git a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/sync_clock_screen.dart b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/sync_clock_screen.dart index d5ad96a1..f59fa3e0 100644 --- a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/sync_clock_screen.dart +++ b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/sync_clock_screen.dart @@ -1,12 +1,12 @@ import 'package:design_system/design_system.dart'; -import 'package:legacy_theme/legacy_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:legacy_theme/legacy_theme.dart'; import 'package:legacy_ui/legacy_ui.dart'; import 'package:navigation/navigation.dart'; +import 'package:settings/src/features/sync_clock/presentation/providers/sync_clock_controller.dart'; import 'package:sf_localizations/sf_localizations.dart'; - -import '../../sound/presentation/state/sound_view_model.dart'; +import 'package:sf_shared/sf_shared.dart'; class SyncClockScreen extends ConsumerWidget { final NavigationContract navigationContract; @@ -15,142 +15,40 @@ class SyncClockScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + ref.listen(syncClockControllerProvider, (prev, next) async { + next.showErrorOn(context); + if (prev != null && + prev.isLoading && + !next.isLoading && + !next.hasError) { + await showSuccessDialog(context, I18n.deviceUpdatedSuccess); + } + }); + + final isLoading = ref.watch( + syncClockControllerProvider.select((s) => s.isLoading), + ); return LegacyPageLayout( title: context.translate(I18n.syncClock), body: Padding( - padding: EdgeInsets.symmetric(horizontal: 18, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), child: Column( children: [ Text(context.translate(I18n.syncClockMessage)), - SizedBox(height: 36), + const SizedBox(height: 36), ], ), ), - footer: const _SaveSection(), - ); - } -} - -// ignore: unused_element -class _OptionsSection extends ConsumerWidget { - const _OptionsSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(soundViewModelProvider.notifier); - - final soundOption = ref.watch( - soundViewModelProvider.select((s) => s.soundOption), - ); - - return SingleChildScrollView( - child: Column( - children: [ - _SectionButton( - title: context.translate(I18n.soundAndVibration), - icon: Icons.volume_up_outlined, - active: soundOption == 'SOUND_AND_VIBRATION', - onPressed: () { - vm.setSoundOption('SOUND_AND_VIBRATION'); - }, - ), - SizedBox(height: 12), - _SectionButton( - title: context.translate(I18n.soundOnly), - icon: Icons.volume_up_outlined, - active: soundOption == 'SOUND', - onPressed: () { - vm.setSoundOption('SOUND'); - }, - ), - SizedBox(height: 12), - _SectionButton( - title: context.translate(I18n.vibrationOnly), - icon: Icons.vibration_outlined, - active: soundOption == 'VIBRATION', - onPressed: () { - vm.setSoundOption('VIBRATION'); - }, - ), - SizedBox(height: 12), - _SectionButton( - title: context.translate(I18n.silent), - icon: Icons.volume_mute_outlined, - active: soundOption == 'SILENT', - onPressed: () { - vm.setSoundOption('SILENT'); - }, - ), - ], - ), - ); - } -} - -class _SectionButton extends ConsumerWidget { - final String title; - final bool active; - final IconData icon; - final VoidCallback onPressed; - - const _SectionButton({ - required this.title, - required this.active, - required this.icon, - required this.onPressed, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - - return SectionButton( - onPressed: onPressed, - icon: Icon( - icon, - color: context.sfColors.legacyPrimary, - size: 36, - ), - iconPadding: 8, - body: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - color: active - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.outline, - ), - ), - Switch( - value: active, - onChanged: (_) { - onPressed(); - }, - ), - ], - ), - ); - } -} - -class _SaveSection extends ConsumerWidget { - const _SaveSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - - final vm = ref.read(soundViewModelProvider.notifier); - - return Padding( - padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: PrimaryButton( - onPressed: vm.submit, - text: context.translate(I18n.save), - color: context.sfColors.legacyPrimary, + footer: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: PrimaryButton( + onPressed: isLoading + ? null + : ref.read(syncClockControllerProvider.notifier).sync, + text: context.translate(I18n.save), + color: context.sfColors.legacyPrimary, + ), ), ); } diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.dart new file mode 100644 index 00000000..cf589f62 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:legacy_device_state/legacy_device_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +part 'timezone_controller.g.dart'; + +@riverpod +class TimezoneController extends _$TimezoneController { + @override + FutureOr build() {} + + Future save({ + required DeviceEntity device, + required int newTimezone, + }) async { + if (newTimezone == device.settings.timezone) return; + + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final updated = device.settings.copyWith(timezone: newTimezone); + await ref + .read(deviceSettingsUpdateProvider) + .updateDeviceSettings(device: device, updatedSettings: updated); + ref.syncDeviceSettings(device, updated); + unawaited( + ref + .read(sfTrackingProvider) + .legacySettingsTimezoneChanged(newTimezone.toString()), + ); + }); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.g.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.g.dart new file mode 100644 index 00000000..53c8df39 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_controller.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timezone_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(TimezoneController) +const timezoneControllerProvider = TimezoneControllerProvider._(); + +final class TimezoneControllerProvider + extends $AsyncNotifierProvider { + const TimezoneControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'timezoneControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$timezoneControllerHash(); + + @$internal + @override + TimezoneController create() => TimezoneController(); +} + +String _$timezoneControllerHash() => + r'1f3122e3ef6ac18760e593a6747b82b8468caec4'; + +abstract class _$TimezoneController extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + build(); + final ref = this.ref as $Ref, void>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, void>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, null); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart new file mode 100644 index 00000000..15894713 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.dart @@ -0,0 +1,11 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'timezone_selection_provider.g.dart'; + +@riverpod +class TimezoneSelection extends _$TimezoneSelection { + @override + int? build() => null; + + void select(int value) => state = value; +} diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart new file mode 100644 index 00000000..1ff4d278 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/providers/timezone_selection_provider.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timezone_selection_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(TimezoneSelection) +const timezoneSelectionProvider = TimezoneSelectionProvider._(); + +final class TimezoneSelectionProvider + extends $NotifierProvider { + const TimezoneSelectionProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'timezoneSelectionProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$timezoneSelectionHash(); + + @$internal + @override + TimezoneSelection create() => TimezoneSelection(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(int? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$timezoneSelectionHash() => r'f108a0d1664e0c7a8423b1ec735745ac1781795b'; + +abstract class _$TimezoneSelection extends $Notifier { + int? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + int?, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart deleted file mode 100644 index 7512fa61..00000000 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.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 'timezone_view_state.dart'; - -final timezoneViewModelProvider = - NotifierProvider.autoDispose( - TimezoneViewModel.new, - ); - -class TimezoneViewModel extends Notifier { - late final DeviceSettingsUpdateDatasource _datasource; - late final SfTrackingRepository _tracking; - - @override - TimezoneViewState build() { - _datasource = ref.read(deviceSettingsUpdateProvider); - _tracking = ref.read(sfTrackingProvider); - Future.microtask(_load); - return const TimezoneViewState(); - } - - void _load() { - final device = ref.read(selectedDeviceProvider).value; - if (device == null) return; - - state = state.copyWith( - timezone: device.settings.timezone, - isLoading: false, - ); - } - - void selectTimezone(int value) { - state = state.copyWith(timezone: value); - } - - Future save() async { - final device = ref.read(selectedDeviceProvider).value; - if (device == null) return; - - if (state.timezone == device.settings.timezone) { - state = state.copyWith(saveSuccess: true); - return; - } - - state = state.copyWith( - isSaving: true, - errorEvent: null, - saveSuccess: false, - ); - - try { - final updatedSettings = device.settings.copyWith( - timezone: state.timezone, - ); - await _datasource.updateDeviceSettings( - device: device, - updatedSettings: updatedSettings, - ); - if (!ref.mounted) return; - ref.syncDeviceSettings(device, updatedSettings); - - unawaited( - _tracking.legacySettingsTimezoneChanged(state.timezone.toString()), - ); - - state = state.copyWith(isSaving: false, saveSuccess: true); - } catch (e) { - if (!ref.mounted) return; - state = state.copyWith( - isSaving: false, - errorEvent: TimezoneErrorEvent.update, - ); - } - } -} diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.dart deleted file mode 100644 index 751460d2..00000000 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'timezone_view_state.freezed.dart'; - -enum TimezoneErrorEvent { update } - -@freezed -abstract class TimezoneViewState with _$TimezoneViewState { - const factory TimezoneViewState({ - @Default(true) bool isLoading, - @Default(false) bool isSaving, - @Default(0) int timezone, - TimezoneErrorEvent? errorEvent, - @Default(false) bool saveSuccess, - }) = _TimezoneViewState; -} diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.freezed.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.freezed.dart deleted file mode 100644 index a77a0c93..00000000 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_state.freezed.dart +++ /dev/null @@ -1,283 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// coverage:ignore-file -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'timezone_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$TimezoneViewState { - - bool get isLoading; bool get isSaving; int get timezone; TimezoneErrorEvent? get errorEvent; bool get saveSuccess; -/// Create a copy of TimezoneViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$TimezoneViewStateCopyWith get copyWith => _$TimezoneViewStateCopyWithImpl(this as TimezoneViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is TimezoneViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSaving, isSaving) || other.isSaving == isSaving)&&(identical(other.timezone, timezone) || other.timezone == timezone)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.saveSuccess, saveSuccess) || other.saveSuccess == saveSuccess)); -} - - -@override -int get hashCode => Object.hash(runtimeType,isLoading,isSaving,timezone,errorEvent,saveSuccess); - -@override -String toString() { - return 'TimezoneViewState(isLoading: $isLoading, isSaving: $isSaving, timezone: $timezone, errorEvent: $errorEvent, saveSuccess: $saveSuccess)'; -} - - -} - -/// @nodoc -abstract mixin class $TimezoneViewStateCopyWith<$Res> { - factory $TimezoneViewStateCopyWith(TimezoneViewState value, $Res Function(TimezoneViewState) _then) = _$TimezoneViewStateCopyWithImpl; -@useResult -$Res call({ - bool isLoading, bool isSaving, int timezone, TimezoneErrorEvent? errorEvent, bool saveSuccess -}); - - - - -} -/// @nodoc -class _$TimezoneViewStateCopyWithImpl<$Res> - implements $TimezoneViewStateCopyWith<$Res> { - _$TimezoneViewStateCopyWithImpl(this._self, this._then); - - final TimezoneViewState _self; - final $Res Function(TimezoneViewState) _then; - -/// Create a copy of TimezoneViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? isSaving = null,Object? timezone = null,Object? errorEvent = freezed,Object? saveSuccess = null,}) { - return _then(_self.copyWith( -isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,isSaving: null == isSaving ? _self.isSaving : isSaving // ignore: cast_nullable_to_non_nullable -as bool,timezone: null == timezone ? _self.timezone : timezone // ignore: cast_nullable_to_non_nullable -as int,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable -as TimezoneErrorEvent?,saveSuccess: null == saveSuccess ? _self.saveSuccess : saveSuccess // ignore: cast_nullable_to_non_nullable -as bool, - )); -} - -} - - -/// Adds pattern-matching-related methods to [TimezoneViewState]. -extension TimezoneViewStatePatterns on TimezoneViewState { -/// 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 Function( _TimezoneViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _TimezoneViewState() 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 Function( _TimezoneViewState value) $default,){ -final _that = this; -switch (_that) { -case _TimezoneViewState(): -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? Function( _TimezoneViewState value)? $default,){ -final _that = this; -switch (_that) { -case _TimezoneViewState() 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 Function( bool isLoading, bool isSaving, int timezone, TimezoneErrorEvent? errorEvent, bool saveSuccess)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _TimezoneViewState() when $default != null: -return $default(_that.isLoading,_that.isSaving,_that.timezone,_that.errorEvent,_that.saveSuccess);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 Function( bool isLoading, bool isSaving, int timezone, TimezoneErrorEvent? errorEvent, bool saveSuccess) $default,) {final _that = this; -switch (_that) { -case _TimezoneViewState(): -return $default(_that.isLoading,_that.isSaving,_that.timezone,_that.errorEvent,_that.saveSuccess);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? Function( bool isLoading, bool isSaving, int timezone, TimezoneErrorEvent? errorEvent, bool saveSuccess)? $default,) {final _that = this; -switch (_that) { -case _TimezoneViewState() when $default != null: -return $default(_that.isLoading,_that.isSaving,_that.timezone,_that.errorEvent,_that.saveSuccess);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _TimezoneViewState implements TimezoneViewState { - const _TimezoneViewState({this.isLoading = true, this.isSaving = false, this.timezone = 0, this.errorEvent, this.saveSuccess = false}); - - -@override@JsonKey() final bool isLoading; -@override@JsonKey() final bool isSaving; -@override@JsonKey() final int timezone; -@override final TimezoneErrorEvent? errorEvent; -@override@JsonKey() final bool saveSuccess; - -/// Create a copy of TimezoneViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$TimezoneViewStateCopyWith<_TimezoneViewState> get copyWith => __$TimezoneViewStateCopyWithImpl<_TimezoneViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimezoneViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSaving, isSaving) || other.isSaving == isSaving)&&(identical(other.timezone, timezone) || other.timezone == timezone)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.saveSuccess, saveSuccess) || other.saveSuccess == saveSuccess)); -} - - -@override -int get hashCode => Object.hash(runtimeType,isLoading,isSaving,timezone,errorEvent,saveSuccess); - -@override -String toString() { - return 'TimezoneViewState(isLoading: $isLoading, isSaving: $isSaving, timezone: $timezone, errorEvent: $errorEvent, saveSuccess: $saveSuccess)'; -} - - -} - -/// @nodoc -abstract mixin class _$TimezoneViewStateCopyWith<$Res> implements $TimezoneViewStateCopyWith<$Res> { - factory _$TimezoneViewStateCopyWith(_TimezoneViewState value, $Res Function(_TimezoneViewState) _then) = __$TimezoneViewStateCopyWithImpl; -@override @useResult -$Res call({ - bool isLoading, bool isSaving, int timezone, TimezoneErrorEvent? errorEvent, bool saveSuccess -}); - - - - -} -/// @nodoc -class __$TimezoneViewStateCopyWithImpl<$Res> - implements _$TimezoneViewStateCopyWith<$Res> { - __$TimezoneViewStateCopyWithImpl(this._self, this._then); - - final _TimezoneViewState _self; - final $Res Function(_TimezoneViewState) _then; - -/// Create a copy of TimezoneViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? isSaving = null,Object? timezone = null,Object? errorEvent = freezed,Object? saveSuccess = null,}) { - return _then(_TimezoneViewState( -isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,isSaving: null == isSaving ? _self.isSaving : isSaving // ignore: cast_nullable_to_non_nullable -as bool,timezone: null == timezone ? _self.timezone : timezone // ignore: cast_nullable_to_non_nullable -as int,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable -as TimezoneErrorEvent?,saveSuccess: null == saveSuccess ? _self.saveSuccess : saveSuccess // ignore: cast_nullable_to_non_nullable -as bool, - )); -} - - -} - -// dart format on diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart index 68998e46..2f8a8af3 100644 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/timezone_screen.dart @@ -1,106 +1,98 @@ import 'package:design_system/design_system.dart'; -import 'package:legacy_theme/legacy_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_device_state/legacy_device_state.dart'; +import 'package:legacy_theme/legacy_theme.dart'; import 'package:legacy_ui/legacy_ui.dart'; +import 'package:settings/src/features/timezone/presentation/providers/timezone_controller.dart'; +import 'package:settings/src/features/timezone/presentation/providers/timezone_selection_provider.dart'; +import 'package:settings/src/features/timezone/timezone_data.dart'; import 'package:sf_localizations/sf_localizations.dart'; - -import '../timezone_data.dart'; -import 'state/timezone_view_model.dart'; +import 'package:sf_shared/sf_shared.dart'; class TimezoneScreen extends ConsumerWidget { const TimezoneScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + ref.listen(timezoneControllerProvider, (prev, next) async { + next.showErrorOn(context); + if (prev != null && + prev.isLoading && + !next.isLoading && + !next.hasError) { + await showSuccessDialog(context, I18n.deviceUpdatedSuccess); + } + }); + final primaryColor = context.sfColors.legacyPrimary; - final state = ref.watch(timezoneViewModelProvider); - final vm = ref.read(timezoneViewModelProvider.notifier); - - ref.listen(timezoneViewModelProvider.select((s) => s.errorEvent), ( - previous, - next, - ) { - if (next != null) { - showTopSnackbar( - context, - message: context.translate(I18n.errorTimezone), - type: MessageType.error, - ); - } - }); - - ref.listen(timezoneViewModelProvider.select((s) => s.saveSuccess), ( - previous, - next, - ) { - if (next && !(previous ?? false)) { - showTopSnackbar( - context, - message: context.translate(I18n.timezoneUpdated), - type: MessageType.success, - ); - } - }); + final device = ref.watch(selectedDeviceProvider).value; + final currentTimezone = device?.settings.timezone ?? 0; + final selectedTimezone = + ref.watch(timezoneSelectionProvider) ?? currentTimezone; + final isSaving = ref.watch( + timezoneControllerProvider.select((s) => s.isLoading), + ); final selected = timezoneEntries - .where((t) => t.$1 == state.timezone) + .where((t) => t.$1 == selectedTimezone) .firstOrNull; final others = timezoneEntries - .where((t) => t.$1 != state.timezone) + .where((t) => t.$1 != selectedTimezone) .toList(); return LegacyPageLayout( title: context.translate(I18n.timezone), - body: state.isLoading - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - children: [ - if (selected != null) ...[ - _SelectedTimezoneCard( - offset: selected.$1, - city: selected.$2, - continent: selected.$3, - label: formatUtcOffset(selected.$1), - primaryColor: primaryColor, - ), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - context.translate(I18n.timezoneOther), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ...others.map( - (tz) => _TimezoneItem( - offset: tz.$1, - city: tz.$2, - continent: tz.$3, - label: formatUtcOffset(tz.$1), - primaryColor: primaryColor, - onTap: () => vm.selectTimezone(tz.$1), - ), - ), - ], + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + if (selected != null) ...[ + _SelectedTimezoneCard( + city: selected.$2, + continent: selected.$3, + label: formatUtcOffset(selected.$1), + primaryColor: primaryColor, ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + context.translate(I18n.timezoneOther), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ...others.map( + (tz) => _TimezoneItem( + city: tz.$2, + continent: tz.$3, + label: formatUtcOffset(tz.$1), + onTap: () => ref + .read(timezoneSelectionProvider.notifier) + .select(tz.$1), + ), + ), + ], + ), footer: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), child: PrimaryButton( - onPressed: state.isSaving + onPressed: isSaving ? null : () async { + if (device == null) return; if (!await guardDeviceCommand(context, ref)) return; - vm.save(); + if (!context.mounted) return; + ref.read(timezoneControllerProvider.notifier).save( + device: device, + newTimezone: selectedTimezone, + ); }, - text: state.isSaving ? '...' : context.translate(I18n.save), + text: isSaving ? '...' : context.translate(I18n.save), color: primaryColor, ), ), @@ -109,14 +101,12 @@ class TimezoneScreen extends ConsumerWidget { } class _SelectedTimezoneCard extends StatelessWidget { - final int offset; final String city; final String continent; final String label; final Color primaryColor; const _SelectedTimezoneCard({ - required this.offset, required this.city, required this.continent, required this.label, @@ -177,19 +167,15 @@ class _SelectedTimezoneCard extends StatelessWidget { } class _TimezoneItem extends StatelessWidget { - final int offset; final String city; final String continent; final String label; - final Color primaryColor; final VoidCallback onTap; const _TimezoneItem({ - required this.offset, required this.city, required this.continent, required this.label, - required this.primaryColor, required this.onTap, }); @@ -230,7 +216,10 @@ class _TimezoneItem extends StatelessWidget { const SizedBox(height: 2), Text( continent, - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), diff --git a/modules/legacy/modules/settings/test/features/sound/sound_controller_test.dart b/modules/legacy/modules/settings/test/features/sound/sound_controller_test.dart new file mode 100644 index 00000000..5207aab6 --- /dev/null +++ b/modules/legacy/modules/settings/test/features/sound/sound_controller_test.dart @@ -0,0 +1,96 @@ +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:settings/src/features/sound/presentation/providers/sound_controller.dart'; +import 'package:sf_infrastructure/sf_infrastructure.dart'; +import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_shared/testing.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +class MockDeviceSettingsUpdateDatasource extends Mock + implements DeviceSettingsUpdateDatasource {} + +const _device = DeviceEntity( + id: 'device-1', + identificator: 'imei-1', + carrierName: 'Watch', + settings: DeviceSettingsEntity(soundMode: 'RINGING'), +); + +void main() { + setUpAll(() { + registerFallbackValue(_device); + registerFallbackValue(const DeviceSettingsEntity()); + }); + + ProviderContainer buildContainer(DeviceSettingsUpdateDatasource ds) { + return makeContainer( + overrides: [ + deviceSettingsUpdateProvider.overrideWithValue(ds), + sfTrackingProvider.overrideWithValue( + SfTrackingRepository(clients: const []), + ), + ], + ); + } + + group('SoundController.save', () { + test('transitions to AsyncData when datasource succeeds', () async { + final ds = MockDeviceSettingsUpdateDatasource(); + when( + () => ds.updateDeviceSettings( + device: any(named: 'device'), + updatedSettings: any(named: 'updatedSettings'), + ), + ).thenAnswer((_) async {}); + + final container = buildContainer(ds); + addTearDown(container.dispose); + + await container + .read(soundControllerProvider.notifier) + .save(device: _device, newMode: 'VIBRATION'); + + expect(container.read(soundControllerProvider), isA>()); + }); + + test('no-ops when new mode equals current', () async { + final ds = MockDeviceSettingsUpdateDatasource(); + final container = buildContainer(ds); + addTearDown(container.dispose); + + await container + .read(soundControllerProvider.notifier) + .save(device: _device, newMode: 'RINGING'); + + verifyNever( + () => ds.updateDeviceSettings( + device: any(named: 'device'), + updatedSettings: any(named: 'updatedSettings'), + ), + ); + }); + + test('exposes AsyncError when datasource fails', () async { + final ds = MockDeviceSettingsUpdateDatasource(); + when( + () => ds.updateDeviceSettings( + device: any(named: 'device'), + updatedSettings: any(named: 'updatedSettings'), + ), + ).thenThrow(const ApiException(message: 'boom', isNetworkError: true)); + + final container = buildContainer(ds); + addTearDown(container.dispose); + + await container + .read(soundControllerProvider.notifier) + .save(device: _device, newMode: 'VIBRATION'); + + final state = container.read(soundControllerProvider); + expect(state, isA>()); + expect(state.error, isA()); + }); + }); +} diff --git a/modules/legacy/modules/settings/test/features/timezone/timezone_controller_test.dart b/modules/legacy/modules/settings/test/features/timezone/timezone_controller_test.dart new file mode 100644 index 00000000..0925f0d8 --- /dev/null +++ b/modules/legacy/modules/settings/test/features/timezone/timezone_controller_test.dart @@ -0,0 +1,96 @@ +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:settings/src/features/timezone/presentation/providers/timezone_controller.dart'; +import 'package:sf_infrastructure/sf_infrastructure.dart'; +import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_shared/testing.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +class MockDeviceSettingsUpdateDatasource extends Mock + implements DeviceSettingsUpdateDatasource {} + +const _device = DeviceEntity( + id: 'device-1', + identificator: 'imei-1', + carrierName: 'Watch', + settings: DeviceSettingsEntity(timezone: 1), +); + +void main() { + setUpAll(() { + registerFallbackValue(_device); + registerFallbackValue(const DeviceSettingsEntity()); + }); + + ProviderContainer buildContainer(DeviceSettingsUpdateDatasource ds) { + return makeContainer( + overrides: [ + deviceSettingsUpdateProvider.overrideWithValue(ds), + sfTrackingProvider.overrideWithValue( + SfTrackingRepository(clients: const []), + ), + ], + ); + } + + group('TimezoneController.save', () { + test('transitions to AsyncData when datasource succeeds', () async { + final ds = MockDeviceSettingsUpdateDatasource(); + when( + () => ds.updateDeviceSettings( + device: any(named: 'device'), + updatedSettings: any(named: 'updatedSettings'), + ), + ).thenAnswer((_) async {}); + + final container = buildContainer(ds); + addTearDown(container.dispose); + + await container + .read(timezoneControllerProvider.notifier) + .save(device: _device, newTimezone: 2); + + expect(container.read(timezoneControllerProvider), isA>()); + }); + + test('no-ops when new timezone equals current', () async { + final ds = MockDeviceSettingsUpdateDatasource(); + final container = buildContainer(ds); + addTearDown(container.dispose); + + await container + .read(timezoneControllerProvider.notifier) + .save(device: _device, newTimezone: 1); + + verifyNever( + () => ds.updateDeviceSettings( + device: any(named: 'device'), + updatedSettings: any(named: 'updatedSettings'), + ), + ); + }); + + test('exposes AsyncError when datasource fails', () async { + final ds = MockDeviceSettingsUpdateDatasource(); + when( + () => ds.updateDeviceSettings( + device: any(named: 'device'), + updatedSettings: any(named: 'updatedSettings'), + ), + ).thenThrow(const ApiException(message: 'boom', isNetworkError: true)); + + final container = buildContainer(ds); + addTearDown(container.dispose); + + await container + .read(timezoneControllerProvider.notifier) + .save(device: _device, newTimezone: 2); + + final state = container.read(timezoneControllerProvider); + expect(state, isA>()); + expect(state.error, isA()); + }); + }); +}