From a197d5bc28deeb88aee91d1bef99490b5c1973bf Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Wed, 22 Apr 2026 01:52:22 +0200 Subject: [PATCH] refactor(legacy-settings): migrate alarm CRUD to AsyncNotifier --- .../alarm/presentation/alarm_screen.dart | 348 +++++++++++------- .../providers/alarm_controller.dart | 33 ++ .../providers/alarm_controller.g.dart | 55 +++ .../providers/alarm_form_provider.dart | 87 +++++ .../providers/alarm_form_provider.g.dart | 108 ++++++ .../providers/alarms_editor_provider.dart | 41 +++ .../providers/alarms_editor_provider.g.dart | 63 ++++ .../providers/alarms_provider.dart | 10 + .../providers/alarms_provider.g.dart | 87 +++++ .../presentation/state/alarm_view_model.dart | 120 ------ .../presentation/state/alarm_view_state.dart | 18 - .../state/alarm_view_state.freezed.dart | 292 --------------- .../widgets/alarm_form_screen.dart | 137 +++---- .../features/alarm/alarm_controller_test.dart | 79 ++++ .../test/features/alarm/alarm_form_test.dart | 98 +++++ .../features/alarm/alarms_editor_test.dart | 67 ++++ 16 files changed, 1002 insertions(+), 641 deletions(-) create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.g.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_form_provider.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_form_provider.g.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.g.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.dart create mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.g.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.freezed.dart create mode 100644 modules/legacy/modules/settings/test/features/alarm/alarm_controller_test.dart create mode 100644 modules/legacy/modules/settings/test/features/alarm/alarm_form_test.dart create mode 100644 modules/legacy/modules/settings/test/features/alarm/alarms_editor_test.dart diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/alarm_screen.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/alarm_screen.dart index 92910701..c5e30f12 100644 --- a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/alarm_screen.dart +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/alarm_screen.dart @@ -1,15 +1,16 @@ import 'package:design_system/design_system.dart'; -import 'package:legacy_theme/legacy_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_device_state/legacy_device_state.dart'; +import 'package:legacy_theme/legacy_theme.dart'; import 'package:navigation/navigation.dart'; import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; -import 'package:settings/src/features/alarm/presentation/state/alarm_view_model.dart'; -import 'package:settings/src/features/alarm/presentation/state/alarm_view_state.dart'; -import 'package:settings/src/features/alarm/presentation/widgets/alarm_form_screen.dart' - show showAlarmForm; +import 'package:settings/src/features/alarm/presentation/providers/alarm_controller.dart'; +import 'package:settings/src/features/alarm/presentation/providers/alarms_editor_provider.dart'; +import 'package:settings/src/features/alarm/presentation/providers/alarms_provider.dart'; +import 'package:settings/src/features/alarm/presentation/widgets/alarm_form_screen.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/sf_shared.dart'; import 'package:utils/utils.dart'; class AlarmScreen extends ConsumerWidget { @@ -19,129 +20,223 @@ class AlarmScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(alarmViewModelProvider.notifier); - final (alarms, isLoading, isSaving, maxAlarms) = ref.watch( - alarmViewModelProvider.select( - (s) => (s.alarms, s.isLoading, s.isSaving, s.maxAlarms), - ), - ); + final device = ref.watch(selectedDeviceProvider).value; final primaryColor = context.sfColors.legacyPrimary; + final maxAlarms = device?.capabilities?.alarms?.maxAlarms ?? 3; - ref.listen(alarmViewModelProvider.select((s) => s.errorEvent), (_, next) { - if (next == null) return; - final message = switch (next) { - AlarmErrorEvent.load || AlarmErrorEvent.save => context.translate( - I18n.alarmError, - ), - AlarmErrorEvent.maxAlarms => context - .translate(I18n.alarmMaxReached) - .replaceAll('{max}', maxAlarms.toString()), - }; - showTopSnackbar(context, message: message, type: MessageType.error); - vm.clearError(); - }); - - ref.listen(alarmViewModelProvider.select((s) => s.saveSuccess), ( - _, - success, - ) { - if (success) { - showTopSnackbar( - context, - message: context.translate(I18n.alarmSaved), - type: MessageType.success, - ); - vm.clearSaveSuccess(); + ref.listen(alarmControllerProvider, (prev, next) { + next.showErrorOn(context); + if (prev != null && + prev.isLoading && + !next.isLoading && + !next.hasError) { + ref.read(alarmsEditorProvider.notifier).clear(); } }); - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.surface, - surfaceTintColor: Colors.transparent, - elevation: 0, - centerTitle: true, - automaticallyImplyLeading: false, - leading: IconButton( - onPressed: () => navigationContract.goBack(), - icon: Icon( - Icons.adaptive.arrow_back, - color: primaryColor, - size: SizeUtils.getByScreen(small: 32, big: 28), - ), - ), - title: Text( - context.translate(I18n.alarm).toUpperCase(), - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 20, big: 19), - fontWeight: FontWeight.w500, - letterSpacing: 0, - color: primaryColor, - ), - ), - actions: [ - if (alarms.length < maxAlarms) - Padding( - padding: EdgeInsets.only( - right: SizeUtils.getByScreen(small: 16, big: 14), - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: primaryColor, - shape: BoxShape.circle, - ), - child: IconButton( - onPressed: () async { - if (!await guardDeviceCommand(context, ref)) return; - if (!context.mounted) return; - _openForm(context, vm); - }, - icon: Icon( - Icons.add, - color: Colors.white, - size: SizeUtils.getByScreen(small: 24, big: 22), - ), - ), - ), - ), - ], + if (device == null) { + return Scaffold( + appBar: _appBar(context, navigationContract, primaryColor), + body: const SizedBox.shrink(), + ); + } + + final alarmsAsync = ref.watch(alarmsProvider(device.id)); + final pendingAlarms = ref.watch(alarmsEditorProvider); + final isSaving = ref.watch( + alarmControllerProvider.select((s) => s.isLoading), + ); + + return alarmsAsync.when( + loading: () => Scaffold( + appBar: _appBar(context, navigationContract, primaryColor), + body: const Center(child: CircularProgressIndicator()), ), - body: SafeArea( - top: false, - child: isLoading - ? const Center(child: CircularProgressIndicator()) - : alarms.isEmpty + error: (_, __) => Scaffold( + appBar: _appBar(context, navigationContract, primaryColor), + body: Center(child: Text(context.translate(I18n.errorGeneric))), + ), + data: (fetchedAlarms) { + final alarms = pendingAlarms ?? fetchedAlarms; + final canAdd = alarms.length < maxAlarms; + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + appBar: _appBar( + context, + navigationContract, + primaryColor, + trailing: canAdd + ? DecoratedBox( + decoration: BoxDecoration( + color: primaryColor, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: () async { + if (!await guardDeviceCommand(context, ref)) return; + if (!context.mounted) return; + if (alarms.length >= maxAlarms) { + await showInfoDialog( + context, + I18n.alarmMaxReached, + args: {'max': maxAlarms.toString()}, + ); + return; + } + if (!context.mounted) return; + _openForm( + context, + ref, + fetchedAlarms: fetchedAlarms, + ); + }, + icon: Icon( + Icons.add, + color: Colors.white, + size: SizeUtils.getByScreen(small: 24, big: 22), + ), + ), + ) + : null, + ), + body: SafeArea( + top: false, + child: alarms.isEmpty ? _EmptyState(primaryColor: primaryColor) : _AlarmList( alarms: alarms, - onEdit: (index, alarm) => - _openForm(context, vm, index: index, alarm: alarm), - onDelete: vm.removeAlarm, - ), - ), - bottomNavigationBar: (!isLoading && alarms.isNotEmpty) - ? Padding( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 10), - child: isSaving - ? const Center(child: CircularProgressIndicator()) - : PrimaryButton( - onPressed: () async { - if (!await guardDeviceCommand(context, ref)) return; - vm.save(); - }, - text: context.translate(I18n.alarmSave), - color: primaryColor, + onEdit: (index, alarm) => _openForm( + context, + ref, + fetchedAlarms: fetchedAlarms, + index: index, + alarm: alarm, ), - ) - : null, + onDelete: (index) async { + final confirmed = await _confirmDelete(context); + if (confirmed != true) return; + if (!context.mounted) return; + final remaining = [...alarms]..removeAt(index); + await ref + .read(alarmControllerProvider.notifier) + .save(deviceId: device.id, alarms: remaining); + if (!context.mounted) return; + final state = ref.read(alarmControllerProvider); + if (state.hasError) return; + await showSuccessDialog(context, I18n.alarmDeleted); + }, + ), + ), + bottomNavigationBar: alarms.isNotEmpty + ? Padding( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + child: isSaving + ? const Center(child: CircularProgressIndicator()) + : PrimaryButton( + onPressed: () async { + if (!await guardDeviceCommand(context, ref)) return; + if (!context.mounted) return; + await ref + .read(alarmControllerProvider.notifier) + .save(deviceId: device.id, alarms: alarms); + if (!context.mounted) return; + final state = + ref.read(alarmControllerProvider); + if (state.hasError) return; + await showSuccessDialog( + context, + I18n.alarmSaved, + ); + if (context.mounted) { + navigationContract.goBack(); + } + }, + text: context.translate(I18n.alarmSave), + color: primaryColor, + ), + ) + : null, + ); + }, + ); + } + + PreferredSizeWidget _appBar( + BuildContext context, + NavigationContract nav, + Color primaryColor, { + Widget? trailing, + }) { + return AppBar( + backgroundColor: Theme.of(context).colorScheme.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + centerTitle: true, + automaticallyImplyLeading: false, + leading: IconButton( + onPressed: () => nav.goBack(), + icon: Icon( + Icons.adaptive.arrow_back, + color: primaryColor, + size: SizeUtils.getByScreen(small: 32, big: 28), + ), + ), + title: Text( + context.translate(I18n.alarm).toUpperCase(), + style: TextStyle( + fontSize: SizeUtils.getByScreen(small: 20, big: 19), + fontWeight: FontWeight.w500, + letterSpacing: 0, + color: primaryColor, + ), + ), + actions: [ + if (trailing != null) + Padding( + padding: EdgeInsets.only( + right: SizeUtils.getByScreen(small: 16, big: 14), + ), + child: trailing, + ), + ], + ); + } + + Future _confirmDelete(BuildContext context) { + final theme = Theme.of(context); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + icon: Icon( + Icons.warning_amber_rounded, + color: theme.colorScheme.error, + size: 40, + ), + title: Text(context.translate(I18n.deleteAlarm)), + content: Text(context.translate(I18n.deleteAlarmConfirm)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(context.translate(I18n.cancel)), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + ), + child: Text(context.translate(I18n.delete)), + ), + ], + ), ); } void _openForm( BuildContext context, - AlarmViewModel vm, { + WidgetRef ref, { + required List fetchedAlarms, int? index, AlarmEntity? alarm, }) { @@ -149,10 +244,15 @@ class AlarmScreen extends ConsumerWidget { context, alarm: alarm, onSave: (result) { + final editor = ref.read(alarmsEditorProvider.notifier); if (index != null) { - vm.updateAlarm(index, result); + editor.replace( + current: fetchedAlarms, + index: index, + alarm: result, + ); } else { - vm.addAlarm(result); + editor.add(current: fetchedAlarms, alarm: result); } }, ); @@ -189,7 +289,7 @@ class _EmptyState extends StatelessWidget { } } -class _AlarmList extends ConsumerWidget { +class _AlarmList extends StatelessWidget { final List alarms; final void Function(int index, AlarmEntity alarm) onEdit; final void Function(int index) onDelete; @@ -201,12 +301,12 @@ class _AlarmList extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return SingleChildScrollView( child: Padding( padding: SizeUtils.getByScreen( - small: EdgeInsets.symmetric(horizontal: 22, vertical: 10), - big: EdgeInsets.symmetric(horizontal: 21, vertical: 8), + small: const EdgeInsets.symmetric(horizontal: 22, vertical: 10), + big: const EdgeInsets.symmetric(horizontal: 21, vertical: 8), ), child: Column( children: alarms.asMap().entries.map((entry) { @@ -222,7 +322,7 @@ class _AlarmList extends ConsumerWidget { } } -class _AlarmCard extends ConsumerWidget { +class _AlarmCard extends StatelessWidget { final AlarmEntity alarm; final VoidCallback onEdit; final VoidCallback onDelete; @@ -234,7 +334,7 @@ class _AlarmCard extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final primaryColor = context.sfColors.legacyPrimary; final frequencyLabel = switch (alarm.frequency) { @@ -254,7 +354,7 @@ class _AlarmCard extends ConsumerWidget { ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(16)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -282,10 +382,10 @@ class _AlarmCard extends ConsumerWidget { ), if (alarm.frequency == AlarmFrequency.custom && alarm.week != null) ...[ - SizedBox(height: 6), + const SizedBox(height: 6), _WeekDaysDisplay(week: alarm.week!, primaryColor: primaryColor), ], - SizedBox(height: 8), + const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.dart new file mode 100644 index 00000000..0f008850 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:settings/src/core/providers/alarm_repository_provider.dart'; +import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; +import 'package:settings/src/features/alarm/presentation/providers/alarms_provider.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +part 'alarm_controller.g.dart'; + +@riverpod +class AlarmController extends _$AlarmController { + @override + FutureOr build() {} + + Future save({ + required String deviceId, + required List alarms, + }) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + await ref + .read(alarmRepositoryProvider) + .upsertAlarms(deviceId: deviceId, alarms: alarms); + ref.invalidate(alarmsProvider(deviceId)); + unawaited( + ref + .read(sfTrackingProvider) + .legacySettingsAlarmsSaved(alarmsCount: alarms.length), + ); + }); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.g.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.g.dart new file mode 100644 index 00000000..7c600132 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_controller.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'alarm_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(AlarmController) +const alarmControllerProvider = AlarmControllerProvider._(); + +final class AlarmControllerProvider + extends $AsyncNotifierProvider { + const AlarmControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'alarmControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$alarmControllerHash(); + + @$internal + @override + AlarmController create() => AlarmController(); +} + +String _$alarmControllerHash() => r'5d63182414c78dd84759e0d2fcc4801e1748f09c'; + +abstract class _$AlarmController 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/alarm/presentation/providers/alarm_form_provider.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_form_provider.dart new file mode 100644 index 00000000..d0cb3044 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_form_provider.dart @@ -0,0 +1,87 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; + +part 'alarm_form_provider.g.dart'; + +class AlarmFormState { + const AlarmFormState({ + required this.durationMinutes, + required this.frequency, + required this.days, + }); + + final int durationMinutes; + final AlarmFrequency frequency; + final List days; + + AlarmFormState copyWith({ + int? durationMinutes, + AlarmFrequency? frequency, + List? days, + }) { + return AlarmFormState( + durationMinutes: durationMinutes ?? this.durationMinutes, + frequency: frequency ?? this.frequency, + days: days ?? this.days, + ); + } + + bool get canSave { + if (frequency == AlarmFrequency.custom && !days.contains(true)) { + return false; + } + return true; + } + + String get timeString { + final hour = (durationMinutes ~/ 60) % 24; + final minute = durationMinutes % 60; + return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; + } + + String? get weekString { + if (frequency != AlarmFrequency.custom) return null; + return days.map((d) => d ? '1' : '0').join(); + } +} + +@riverpod +class AlarmFormNotifier extends _$AlarmFormNotifier { + @override + AlarmFormState build(AlarmEntity? initialAlarm) { + return _stateFor(initialAlarm); + } + + void setDurationMinutes(int minutes) => + state = state.copyWith(durationMinutes: minutes); + + void setFrequency(AlarmFrequency frequency) => + state = state.copyWith(frequency: frequency); + + void toggleDay(int index) { + if (index < 0 || index >= state.days.length) return; + final updated = [...state.days]; + updated[index] = !updated[index]; + state = state.copyWith(days: updated); + } + + static AlarmFormState _stateFor(AlarmEntity? alarm) { + if (alarm == null) { + return const AlarmFormState( + durationMinutes: 8 * 60, + frequency: AlarmFrequency.once, + days: [false, false, false, false, false, false, false], + ); + } + final parts = alarm.time.split(':'); + final hour = int.tryParse(parts[0]) ?? 8; + final minute = int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0; + return AlarmFormState( + durationMinutes: hour * 60 + minute, + frequency: alarm.frequency, + days: alarm.week != null + ? alarm.week!.padRight(7, '0').split('').map((c) => c == '1').toList() + : List.filled(7, false), + ); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_form_provider.g.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_form_provider.g.dart new file mode 100644 index 00000000..8c56aa00 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarm_form_provider.g.dart @@ -0,0 +1,108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'alarm_form_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(AlarmFormNotifier) +const alarmFormProvider = AlarmFormNotifierFamily._(); + +final class AlarmFormNotifierProvider + extends $NotifierProvider { + const AlarmFormNotifierProvider._({ + required AlarmFormNotifierFamily super.from, + required AlarmEntity? super.argument, + }) : super( + retry: null, + name: r'alarmFormProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$alarmFormNotifierHash(); + + @override + String toString() { + return r'alarmFormProvider' + '' + '($argument)'; + } + + @$internal + @override + AlarmFormNotifier create() => AlarmFormNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AlarmFormState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is AlarmFormNotifierProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$alarmFormNotifierHash() => r'cd344fbbaecfab337e40b472e263afe88dacdb94'; + +final class AlarmFormNotifierFamily extends $Family + with + $ClassFamilyOverride< + AlarmFormNotifier, + AlarmFormState, + AlarmFormState, + AlarmFormState, + AlarmEntity? + > { + const AlarmFormNotifierFamily._() + : super( + retry: null, + name: r'alarmFormProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + AlarmFormNotifierProvider call(AlarmEntity? initialAlarm) => + AlarmFormNotifierProvider._(argument: initialAlarm, from: this); + + @override + String toString() => r'alarmFormProvider'; +} + +abstract class _$AlarmFormNotifier extends $Notifier { + late final _$args = ref.$arg as AlarmEntity?; + AlarmEntity? get initialAlarm => _$args; + + AlarmFormState build(AlarmEntity? initialAlarm); + @$mustCallSuper + @override + void runBuild() { + final created = build(_$args); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + AlarmFormState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.dart new file mode 100644 index 00000000..8fdbe45d --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.dart @@ -0,0 +1,41 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; + +part 'alarms_editor_provider.g.dart'; + +@riverpod +class AlarmsEditor extends _$AlarmsEditor { + @override + List? build() => null; + + void add({ + required List current, + required AlarmEntity alarm, + }) { + final base = state ?? current; + state = [...base, alarm]; + } + + void replace({ + required List current, + required int index, + required AlarmEntity alarm, + }) { + final base = state ?? current; + if (index < 0 || index >= base.length) return; + final updated = [...base]; + updated[index] = alarm; + state = updated; + } + + void remove({ + required List current, + required int index, + }) { + final base = state ?? current; + if (index < 0 || index >= base.length) return; + state = [...base]..removeAt(index); + } + + void clear() => state = null; +} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.g.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.g.dart new file mode 100644 index 00000000..00fceede --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_editor_provider.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'alarms_editor_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(AlarmsEditor) +const alarmsEditorProvider = AlarmsEditorProvider._(); + +final class AlarmsEditorProvider + extends $NotifierProvider?> { + const AlarmsEditorProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'alarmsEditorProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$alarmsEditorHash(); + + @$internal + @override + AlarmsEditor create() => AlarmsEditor(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider?>(value), + ); + } +} + +String _$alarmsEditorHash() => r'3969010eeae051c6eba6e40ee1f3f4a93c38d347'; + +abstract class _$AlarmsEditor extends $Notifier?> { + List? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref?, List?>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier?, List?>, + List?, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.dart new file mode 100644 index 00000000..79b9d695 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.dart @@ -0,0 +1,10 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:settings/src/core/providers/alarm_repository_provider.dart'; +import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; + +part 'alarms_provider.g.dart'; + +@riverpod +Future> alarms(Ref ref, String deviceId) async { + return ref.watch(alarmRepositoryProvider).getAlarms(deviceId: deviceId); +} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.g.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.g.dart new file mode 100644 index 00000000..4d7c9757 --- /dev/null +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/providers/alarms_provider.g.dart @@ -0,0 +1,87 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'alarms_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(alarms) +const alarmsProvider = AlarmsFamily._(); + +final class AlarmsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + const AlarmsProvider._({ + required AlarmsFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'alarmsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$alarmsHash(); + + @override + String toString() { + return r'alarmsProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as String; + return alarms(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is AlarmsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$alarmsHash() => r'1139a974b215a01cf1c13e33a0fdb527898a804e'; + +final class AlarmsFamily extends $Family + with $FunctionalFamilyOverride>, String> { + const AlarmsFamily._() + : super( + retry: null, + name: r'alarmsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + AlarmsProvider call(String deviceId) => + AlarmsProvider._(argument: deviceId, from: this); + + @override + String toString() => r'alarmsProvider'; +} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart deleted file mode 100644 index ae93e635..00000000 --- a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sf_shared/sf_shared.dart'; -import 'package:settings/src/core/domain/repositories/alarm_repository.dart'; -import 'package:settings/src/core/providers/alarm_repository_provider.dart'; -import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; -import 'package:settings/src/features/alarm/presentation/state/alarm_view_state.dart'; -import 'package:sf_tracking/sf_tracking.dart'; - -final alarmViewModelProvider = - NotifierProvider.autoDispose( - AlarmViewModel.new, - ); - -class AlarmViewModel extends Notifier { - late final AlarmRepository _repository; - late final SfTrackingRepository _tracking; - - @override - AlarmViewState build() { - _repository = ref.read(alarmRepositoryProvider); - _tracking = ref.read(sfTrackingProvider); - - final capabilities = ref.read(selectedDeviceProvider).value?.capabilities; - final maxAlarms = capabilities?.alarms?.maxAlarms ?? 3; - - Future.microtask(_load); - return AlarmViewState(maxAlarms: maxAlarms); - } - - String? get _deviceId => ref.read(selectedDeviceProvider).value?.id; - - Future _load() async { - final deviceId = _deviceId; - if (deviceId == null) { - state = state.copyWith(isLoading: false); - return; - } - - try { - final alarms = await _repository.getAlarms(deviceId: deviceId); - if (!ref.mounted) return; - state = state.copyWith(alarms: alarms, isLoading: false); - } catch (_) { - if (!ref.mounted) return; - state = state.copyWith( - isLoading: false, - errorEvent: AlarmErrorEvent.load, - ); - } - } - - void addAlarm(AlarmEntity alarm) { - if (state.alarms.length >= state.maxAlarms) { - state = state.copyWith(errorEvent: AlarmErrorEvent.maxAlarms); - return; - } - state = state.copyWith( - alarms: [...state.alarms, alarm], - errorEvent: null, - ); - } - - void updateAlarm(int index, AlarmEntity alarm) { - if (index < 0 || index >= state.alarms.length) return; - final updated = [...state.alarms]; - updated[index] = alarm; - state = state.copyWith(alarms: updated, errorEvent: null); - } - - void removeAlarm(int index) { - if (index < 0 || index >= state.alarms.length) return; - final updated = [...state.alarms]..removeAt(index); - state = state.copyWith(alarms: updated, errorEvent: null); - } - - Future save() async { - final deviceId = _deviceId; - if (deviceId == null) return; - - state = state.copyWith( - isSaving: true, - saveSuccess: false, - errorEvent: null, - ); - - try { - final alarms = await _repository.upsertAlarms( - deviceId: deviceId, - alarms: state.alarms, - ); - if (!ref.mounted) return; - - state = state.copyWith( - isSaving: false, - alarms: alarms, - saveSuccess: true, - ); - - unawaited( - _tracking.legacySettingsAlarmsSaved(alarmsCount: alarms.length), - ); - } catch (_) { - if (!ref.mounted) return; - state = state.copyWith( - isSaving: false, - errorEvent: AlarmErrorEvent.save, - ); - } - } - - void clearError() { - if (state.errorEvent != null) state = state.copyWith(errorEvent: null); - } - - void clearSaveSuccess() { - if (state.saveSuccess) state = state.copyWith(saveSuccess: false); - } -} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.dart deleted file mode 100644 index b54b7bae..00000000 --- a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; - -part 'alarm_view_state.freezed.dart'; - -enum AlarmErrorEvent { load, save, maxAlarms } - -@freezed -abstract class AlarmViewState with _$AlarmViewState { - const factory AlarmViewState({ - @Default([]) List alarms, - @Default(true) bool isLoading, - @Default(false) bool isSaving, - @Default(3) int maxAlarms, - AlarmErrorEvent? errorEvent, - @Default(false) bool saveSuccess, - }) = _AlarmViewState; -} diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.freezed.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.freezed.dart deleted file mode 100644 index b93f1a27..00000000 --- a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_state.freezed.dart +++ /dev/null @@ -1,292 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// coverage:ignore-file -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'alarm_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$AlarmViewState { - - List get alarms; bool get isLoading; bool get isSaving; int get maxAlarms; AlarmErrorEvent? get errorEvent; bool get saveSuccess; -/// Create a copy of AlarmViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$AlarmViewStateCopyWith get copyWith => _$AlarmViewStateCopyWithImpl(this as AlarmViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is AlarmViewState&&const DeepCollectionEquality().equals(other.alarms, alarms)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSaving, isSaving) || other.isSaving == isSaving)&&(identical(other.maxAlarms, maxAlarms) || other.maxAlarms == maxAlarms)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.saveSuccess, saveSuccess) || other.saveSuccess == saveSuccess)); -} - - -@override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(alarms),isLoading,isSaving,maxAlarms,errorEvent,saveSuccess); - -@override -String toString() { - return 'AlarmViewState(alarms: $alarms, isLoading: $isLoading, isSaving: $isSaving, maxAlarms: $maxAlarms, errorEvent: $errorEvent, saveSuccess: $saveSuccess)'; -} - - -} - -/// @nodoc -abstract mixin class $AlarmViewStateCopyWith<$Res> { - factory $AlarmViewStateCopyWith(AlarmViewState value, $Res Function(AlarmViewState) _then) = _$AlarmViewStateCopyWithImpl; -@useResult -$Res call({ - List alarms, bool isLoading, bool isSaving, int maxAlarms, AlarmErrorEvent? errorEvent, bool saveSuccess -}); - - - - -} -/// @nodoc -class _$AlarmViewStateCopyWithImpl<$Res> - implements $AlarmViewStateCopyWith<$Res> { - _$AlarmViewStateCopyWithImpl(this._self, this._then); - - final AlarmViewState _self; - final $Res Function(AlarmViewState) _then; - -/// Create a copy of AlarmViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? alarms = null,Object? isLoading = null,Object? isSaving = null,Object? maxAlarms = null,Object? errorEvent = freezed,Object? saveSuccess = null,}) { - return _then(_self.copyWith( -alarms: null == alarms ? _self.alarms : alarms // ignore: cast_nullable_to_non_nullable -as List,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,maxAlarms: null == maxAlarms ? _self.maxAlarms : maxAlarms // ignore: cast_nullable_to_non_nullable -as int,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable -as AlarmErrorEvent?,saveSuccess: null == saveSuccess ? _self.saveSuccess : saveSuccess // ignore: cast_nullable_to_non_nullable -as bool, - )); -} - -} - - -/// Adds pattern-matching-related methods to [AlarmViewState]. -extension AlarmViewStatePatterns on AlarmViewState { -/// 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( _AlarmViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _AlarmViewState() 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( _AlarmViewState value) $default,){ -final _that = this; -switch (_that) { -case _AlarmViewState(): -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( _AlarmViewState value)? $default,){ -final _that = this; -switch (_that) { -case _AlarmViewState() 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( List alarms, bool isLoading, bool isSaving, int maxAlarms, AlarmErrorEvent? errorEvent, bool saveSuccess)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _AlarmViewState() when $default != null: -return $default(_that.alarms,_that.isLoading,_that.isSaving,_that.maxAlarms,_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( List alarms, bool isLoading, bool isSaving, int maxAlarms, AlarmErrorEvent? errorEvent, bool saveSuccess) $default,) {final _that = this; -switch (_that) { -case _AlarmViewState(): -return $default(_that.alarms,_that.isLoading,_that.isSaving,_that.maxAlarms,_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( List alarms, bool isLoading, bool isSaving, int maxAlarms, AlarmErrorEvent? errorEvent, bool saveSuccess)? $default,) {final _that = this; -switch (_that) { -case _AlarmViewState() when $default != null: -return $default(_that.alarms,_that.isLoading,_that.isSaving,_that.maxAlarms,_that.errorEvent,_that.saveSuccess);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _AlarmViewState implements AlarmViewState { - const _AlarmViewState({final List alarms = const [], this.isLoading = true, this.isSaving = false, this.maxAlarms = 3, this.errorEvent, this.saveSuccess = false}): _alarms = alarms; - - - final List _alarms; -@override@JsonKey() List get alarms { - if (_alarms is EqualUnmodifiableListView) return _alarms; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_alarms); -} - -@override@JsonKey() final bool isLoading; -@override@JsonKey() final bool isSaving; -@override@JsonKey() final int maxAlarms; -@override final AlarmErrorEvent? errorEvent; -@override@JsonKey() final bool saveSuccess; - -/// Create a copy of AlarmViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$AlarmViewStateCopyWith<_AlarmViewState> get copyWith => __$AlarmViewStateCopyWithImpl<_AlarmViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _AlarmViewState&&const DeepCollectionEquality().equals(other._alarms, _alarms)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isSaving, isSaving) || other.isSaving == isSaving)&&(identical(other.maxAlarms, maxAlarms) || other.maxAlarms == maxAlarms)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.saveSuccess, saveSuccess) || other.saveSuccess == saveSuccess)); -} - - -@override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_alarms),isLoading,isSaving,maxAlarms,errorEvent,saveSuccess); - -@override -String toString() { - return 'AlarmViewState(alarms: $alarms, isLoading: $isLoading, isSaving: $isSaving, maxAlarms: $maxAlarms, errorEvent: $errorEvent, saveSuccess: $saveSuccess)'; -} - - -} - -/// @nodoc -abstract mixin class _$AlarmViewStateCopyWith<$Res> implements $AlarmViewStateCopyWith<$Res> { - factory _$AlarmViewStateCopyWith(_AlarmViewState value, $Res Function(_AlarmViewState) _then) = __$AlarmViewStateCopyWithImpl; -@override @useResult -$Res call({ - List alarms, bool isLoading, bool isSaving, int maxAlarms, AlarmErrorEvent? errorEvent, bool saveSuccess -}); - - - - -} -/// @nodoc -class __$AlarmViewStateCopyWithImpl<$Res> - implements _$AlarmViewStateCopyWith<$Res> { - __$AlarmViewStateCopyWithImpl(this._self, this._then); - - final _AlarmViewState _self; - final $Res Function(_AlarmViewState) _then; - -/// Create a copy of AlarmViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? alarms = null,Object? isLoading = null,Object? isSaving = null,Object? maxAlarms = null,Object? errorEvent = freezed,Object? saveSuccess = null,}) { - return _then(_AlarmViewState( -alarms: null == alarms ? _self._alarms : alarms // ignore: cast_nullable_to_non_nullable -as List,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,maxAlarms: null == maxAlarms ? _self.maxAlarms : maxAlarms // ignore: cast_nullable_to_non_nullable -as int,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable -as AlarmErrorEvent?,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/alarm/presentation/widgets/alarm_form_screen.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/widgets/alarm_form_screen.dart index 8bf05b48..e50b755b 100644 --- a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/widgets/alarm_form_screen.dart +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/widgets/alarm_form_screen.dart @@ -1,9 +1,10 @@ -import 'package:legacy_theme/legacy_theme.dart'; import 'package:flutter/cupertino.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:settings/src/features/alarm/domain/entities/alarm_entity.dart'; +import 'package:settings/src/features/alarm/presentation/providers/alarm_form_provider.dart'; import 'package:sf_localizations/sf_localizations.dart'; import 'package:utils/utils.dart'; @@ -16,78 +17,41 @@ void showAlarmForm( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (_) => _AlarmFormSheet(alarm: alarm, onSave: onSave), + builder: (_) => _AlarmFormSheet(initialAlarm: alarm, onSave: onSave), ); } -class _AlarmFormSheet extends ConsumerStatefulWidget { - final AlarmEntity? alarm; +class _AlarmFormSheet extends ConsumerWidget { + final AlarmEntity? initialAlarm; final ValueChanged onSave; - const _AlarmFormSheet({this.alarm, required this.onSave}); + const _AlarmFormSheet({this.initialAlarm, required this.onSave}); @override - ConsumerState<_AlarmFormSheet> createState() => _AlarmFormSheetState(); -} - -class _AlarmFormSheetState extends ConsumerState<_AlarmFormSheet> { - late Duration _duration; - late AlarmFrequency _frequency; - late List _days; - - @override - void initState() { - super.initState(); - if (widget.alarm != null) { - final parts = widget.alarm!.time.split(':'); - final hour = int.tryParse(parts[0]) ?? 8; - final minute = int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0; - _duration = Duration(hours: hour, minutes: minute); - _frequency = widget.alarm!.frequency; - _days = widget.alarm!.week != null - ? widget.alarm!.week!.padRight(7, '0').split('').map((c) => c == '1').toList() - : List.filled(7, false); - } else { - _duration = const Duration(hours: 8); - _frequency = AlarmFrequency.once; - _days = List.filled(7, false); - } - } - - bool get _canSave { - if (_frequency == AlarmFrequency.custom && !_days.contains(true)) { - return false; - } - return true; - } - - void _save() { - if (!_canSave) return; - - final hour = _duration.inHours % 24; - final minute = _duration.inMinutes % 60; - final time = - '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; - - final week = _frequency == AlarmFrequency.custom - ? _days.map((day) => day ? '1' : '0').join() - : null; - - widget.onSave(AlarmEntity(time: time, frequency: _frequency, week: week)); - Navigator.pop(context); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final primaryColor = context.sfColors.legacyPrimary; final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final formState = ref.watch(alarmFormProvider(initialAlarm)); + final notifier = ref.read(alarmFormProvider(initialAlarm).notifier); + + void onSubmit() { + if (!formState.canSave) return; + onSave( + AlarmEntity( + time: formState.timeString, + frequency: formState.frequency, + week: formState.weekString, + ), + ); + Navigator.pop(context); + } return Padding( padding: EdgeInsets.only(bottom: bottomInset), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: SafeArea( top: false, @@ -117,7 +81,7 @@ class _AlarmFormSheetState extends ConsumerState<_AlarmFormSheet> { children: [ Text( context.translate( - widget.alarm != null + initialAlarm != null ? I18n.editAlarm : I18n.addAlarm, ), @@ -128,13 +92,16 @@ class _AlarmFormSheetState extends ConsumerState<_AlarmFormSheet> { ), ), TextButton( - onPressed: _canSave ? _save : null, + onPressed: formState.canSave ? onSubmit : null, child: Text( context.translate(I18n.save), style: TextStyle( - color: _canSave ? primaryColor : Colors.grey, + color: formState.canSave + ? primaryColor + : Colors.grey, fontWeight: FontWeight.w600, - fontSize: SizeUtils.getByScreen(small: 16, big: 17), + fontSize: + SizeUtils.getByScreen(small: 16, big: 17), ), ), ), @@ -144,15 +111,17 @@ class _AlarmFormSheetState extends ConsumerState<_AlarmFormSheet> { Container( height: SizeUtils.getByScreen(small: 160, big: 180), decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(16)), + borderRadius: + const BorderRadius.all(Radius.circular(16)), color: Colors.grey.shade50, ), child: CupertinoTimerPicker( mode: CupertinoTimerPickerMode.hm, - initialTimerDuration: _duration, - onTimerDurationChanged: (duration) { - setState(() => _duration = duration); - }, + initialTimerDuration: Duration( + minutes: formState.durationMinutes, + ), + onTimerDurationChanged: (duration) => + notifier.setDurationMinutes(duration.inMinutes), ), ), SizedBox(height: SizeUtils.getByScreen(small: 20, big: 22)), @@ -163,38 +132,32 @@ class _AlarmFormSheetState extends ConsumerState<_AlarmFormSheet> { fontWeight: FontWeight.w500, ), ), - SizedBox(height: 8), + const SizedBox(height: 8), _RadioOption( label: context.translate(I18n.once), - isSelected: _frequency == AlarmFrequency.once, + isSelected: formState.frequency == AlarmFrequency.once, primaryColor: primaryColor, - onTap: () => - setState(() => _frequency = AlarmFrequency.once), + onTap: () => notifier.setFrequency(AlarmFrequency.once), ), _RadioOption( label: context.translate(I18n.daily), - isSelected: _frequency == AlarmFrequency.every, + isSelected: formState.frequency == AlarmFrequency.every, primaryColor: primaryColor, - onTap: () => - setState(() => _frequency = AlarmFrequency.every), + onTap: () => notifier.setFrequency(AlarmFrequency.every), ), _RadioOption( label: context.translate(I18n.selectDay), - isSelected: _frequency == AlarmFrequency.custom, + isSelected: formState.frequency == AlarmFrequency.custom, primaryColor: primaryColor, - onTap: () => - setState(() => _frequency = AlarmFrequency.custom), + onTap: () => notifier.setFrequency(AlarmFrequency.custom), ), - if (_frequency == AlarmFrequency.custom) ...[ - SizedBox(height: SizeUtils.getByScreen(small: 12, big: 14)), + if (formState.frequency == AlarmFrequency.custom) ...[ + SizedBox( + height: SizeUtils.getByScreen(small: 12, big: 14), + ), WeekDayChips.multi( - selectedDays: _days, - onToggle: (index) { - setState(() { - _days = _days.toList(); - _days[index] = !_days[index]; - }); - }, + selectedDays: formState.days, + onToggle: notifier.toggleDay, ), ], SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)), @@ -237,7 +200,7 @@ class _RadioOption extends StatelessWidget { color: isSelected ? primaryColor : Colors.grey, size: SizeUtils.getByScreen(small: 22, big: 24), ), - SizedBox(width: 12), + const SizedBox(width: 12), Text( label, style: TextStyle( diff --git a/modules/legacy/modules/settings/test/features/alarm/alarm_controller_test.dart b/modules/legacy/modules/settings/test/features/alarm/alarm_controller_test.dart new file mode 100644 index 00000000..5571bc41 --- /dev/null +++ b/modules/legacy/modules/settings/test/features/alarm/alarm_controller_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:settings/src/core/domain/repositories/alarm_repository.dart'; +import 'package:settings/src/core/providers/alarm_repository_provider.dart'; +import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; +import 'package:settings/src/features/alarm/presentation/providers/alarm_controller.dart'; +import 'package:sf_infrastructure/sf_infrastructure.dart'; +import 'package:sf_shared/testing.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +class MockAlarmRepository extends Mock implements AlarmRepository {} + +const _alarm = AlarmEntity(time: '08:00', frequency: AlarmFrequency.once); + +void main() { + setUpAll(() { + registerFallbackValue(const []); + }); + + ProviderContainer buildContainer(AlarmRepository repo) { + return makeContainer( + overrides: [ + alarmRepositoryProvider.overrideWithValue(repo), + sfTrackingProvider.overrideWithValue( + SfTrackingRepository(clients: const []), + ), + ], + ); + } + + group('AlarmController.save', () { + test('transitions to AsyncData and invalidates alarmsProvider', () async { + final repo = MockAlarmRepository(); + when( + () => repo.upsertAlarms( + deviceId: any(named: 'deviceId'), + alarms: any(named: 'alarms'), + ), + ).thenAnswer((_) async => const [_alarm]); + when(() => repo.getAlarms(deviceId: any(named: 'deviceId'))) + .thenAnswer((_) async => const [_alarm]); + + final container = buildContainer(repo); + addTearDown(container.dispose); + + await container + .read(alarmControllerProvider.notifier) + .save(deviceId: 'device-1', alarms: const [_alarm]); + + expect(container.read(alarmControllerProvider), isA>()); + + verify( + () => repo.upsertAlarms(deviceId: 'device-1', alarms: [_alarm]), + ).called(1); + }); + + test('exposes AsyncError when the repository fails', () async { + final repo = MockAlarmRepository(); + when( + () => repo.upsertAlarms( + deviceId: any(named: 'deviceId'), + alarms: any(named: 'alarms'), + ), + ).thenThrow(const ApiException(message: 'boom', isNetworkError: true)); + + final container = buildContainer(repo); + addTearDown(container.dispose); + + await container + .read(alarmControllerProvider.notifier) + .save(deviceId: 'device-1', alarms: const [_alarm]); + + final state = container.read(alarmControllerProvider); + expect(state, isA>()); + expect(state.error, isA()); + }); + }); +} diff --git a/modules/legacy/modules/settings/test/features/alarm/alarm_form_test.dart b/modules/legacy/modules/settings/test/features/alarm/alarm_form_test.dart new file mode 100644 index 00000000..9412bfc0 --- /dev/null +++ b/modules/legacy/modules/settings/test/features/alarm/alarm_form_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; +import 'package:settings/src/features/alarm/presentation/providers/alarm_form_provider.dart'; +import 'package:sf_shared/testing.dart'; + +void main() { + group('AlarmFormNotifier', () { + test('defaults to 08:00 once when no initial alarm', () { + final container = makeContainer(); + addTearDown(container.dispose); + + final state = container.read(alarmFormProvider(null)); + expect(state.durationMinutes, 480); + expect(state.frequency, AlarmFrequency.once); + expect(state.days, [false, false, false, false, false, false, false]); + expect(state.canSave, isTrue); + expect(state.timeString, '08:00'); + expect(state.weekString, isNull); + }); + + test('initializes from existing alarm with custom week', () { + final container = makeContainer(); + addTearDown(container.dispose); + + const existing = AlarmEntity( + time: '21:45', + frequency: AlarmFrequency.custom, + week: '1010100', + ); + final state = container.read(alarmFormProvider(existing)); + expect(state.durationMinutes, 21 * 60 + 45); + expect(state.frequency, AlarmFrequency.custom); + expect(state.days, [true, false, true, false, true, false, false]); + expect(state.timeString, '21:45'); + expect(state.weekString, '1010100'); + }); + + test('setFrequency updates frequency', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmFormProvider(null).notifier) + .setFrequency(AlarmFrequency.every); + + expect( + container.read(alarmFormProvider(null)).frequency, + AlarmFrequency.every, + ); + }); + + test('setDurationMinutes updates time', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmFormProvider(null).notifier) + .setDurationMinutes(7 * 60 + 30); + + expect(container.read(alarmFormProvider(null)).timeString, '07:30'); + }); + + test('toggleDay flips day at index', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container.read(alarmFormProvider(null).notifier).toggleDay(2); + + expect( + container.read(alarmFormProvider(null)).days, + [false, false, true, false, false, false, false], + ); + }); + + test('canSave false when custom frequency without days selected', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmFormProvider(null).notifier) + .setFrequency(AlarmFrequency.custom); + + expect(container.read(alarmFormProvider(null)).canSave, isFalse); + }); + + test('canSave true when custom frequency with at least one day', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmFormProvider(null).notifier) + .setFrequency(AlarmFrequency.custom); + container.read(alarmFormProvider(null).notifier).toggleDay(3); + + expect(container.read(alarmFormProvider(null)).canSave, isTrue); + }); + }); +} diff --git a/modules/legacy/modules/settings/test/features/alarm/alarms_editor_test.dart b/modules/legacy/modules/settings/test/features/alarm/alarms_editor_test.dart new file mode 100644 index 00000000..665ce6e2 --- /dev/null +++ b/modules/legacy/modules/settings/test/features/alarm/alarms_editor_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; +import 'package:settings/src/features/alarm/presentation/providers/alarms_editor_provider.dart'; +import 'package:sf_shared/testing.dart'; + +const _a1 = AlarmEntity(time: '08:00', frequency: AlarmFrequency.once); +const _a2 = AlarmEntity(time: '09:30', frequency: AlarmFrequency.every); +const _a3 = AlarmEntity( + time: '10:00', + frequency: AlarmFrequency.custom, + week: '1010101', +); + +void main() { + group('AlarmsEditor', () { + test('defaults to null', () { + final container = makeContainer(); + addTearDown(container.dispose); + expect(container.read(alarmsEditorProvider), isNull); + }); + + test('add appends alarm to current list', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmsEditorProvider.notifier) + .add(current: const [_a1], alarm: _a2); + + expect(container.read(alarmsEditorProvider), [_a1, _a2]); + }); + + test('replace swaps alarm at index', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmsEditorProvider.notifier) + .replace(current: const [_a1, _a2], index: 1, alarm: _a3); + + expect(container.read(alarmsEditorProvider), [_a1, _a3]); + }); + + test('remove drops alarm at index', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmsEditorProvider.notifier) + .remove(current: const [_a1, _a2], index: 0); + + expect(container.read(alarmsEditorProvider), [_a2]); + }); + + test('clear resets state to null', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container + .read(alarmsEditorProvider.notifier) + .add(current: const [], alarm: _a1); + container.read(alarmsEditorProvider.notifier).clear(); + + expect(container.read(alarmsEditorProvider), isNull); + }); + }); +}