From ba7634893664d7705e3d64ebfe56d29e23cac97e Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Wed, 22 Apr 2026 22:35:27 +0200 Subject: [PATCH] refactor(device_management): migrate scheduled_activities to Riverpod --- .../providers/activity_form_provider.dart | 101 +++++++ .../providers/activity_form_provider.g.dart | 114 +++++++ .../scheduled_activities_controller.dart | 153 ++++++++++ .../scheduled_activities_controller.g.dart | 57 ++++ .../scheduled_activities_provider.dart | 20 ++ .../scheduled_activities_provider.g.dart | 92 ++++++ .../scheduled_activities_screen.dart | 136 +++++---- .../scheduled_activities_view_model.dart | 223 -------------- .../scheduled_activities_view_state.dart | 14 - ...heduled_activities_view_state.freezed.dart | 283 ------------------ .../widgets/activity_form_sheet.dart | 167 +++++------ .../confirm_delete_activity_dialog.dart | 77 ----- .../presentation/widgets/day_timeline.dart | 66 ++-- .../scheduled_activities_controller_test.dart | 177 +++++++++++ packages/sf_localizations/assets/l10n/de.json | 3 + packages/sf_localizations/assets/l10n/en.json | 3 + packages/sf_localizations/assets/l10n/es.json | 3 + packages/sf_localizations/assets/l10n/fr.json | 3 + packages/sf_localizations/assets/l10n/it.json | 3 + packages/sf_localizations/assets/l10n/pt.json | 3 + .../lib/src/generated/i18n.dart | 3 + 21 files changed, 945 insertions(+), 756 deletions(-) create mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.dart create mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.g.dart create mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart create mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.g.dart create mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.dart create mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.g.dart delete mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart delete mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_state.dart delete mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_state.freezed.dart delete mode 100644 modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/confirm_delete_activity_dialog.dart create mode 100644 modules/legacy/modules/device_management/test/features/scheduled_activities/scheduled_activities_controller_test.dart diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.dart new file mode 100644 index 00000000..53cbbb9e --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.dart @@ -0,0 +1,101 @@ +import 'package:device_management/src/features/scheduled_activities/domain/entities/scheduled_activity_entity.dart'; +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'activity_form_provider.g.dart'; + +class ActivityFormState { + const ActivityFormState({ + required this.weekDay, + required this.startTime, + required this.endTime, + this.showStartPicker = false, + this.showEndPicker = false, + }); + + final int weekDay; + final TimeOfDay startTime; + final TimeOfDay endTime; + final bool showStartPicker; + final bool showEndPicker; + + bool get isTimeValid { + final startMinutes = startTime.hour * 60 + startTime.minute; + final endMinutes = endTime.hour * 60 + endTime.minute; + return startMinutes < endMinutes; + } + + String get period => + '${startTime.hour.toString().padLeft(2, '0')}' + '${startTime.minute.toString().padLeft(2, '0')}' + '${endTime.hour.toString().padLeft(2, '0')}' + '${endTime.minute.toString().padLeft(2, '0')}'; + + ActivityFormState copyWith({ + int? weekDay, + TimeOfDay? startTime, + TimeOfDay? endTime, + bool? showStartPicker, + bool? showEndPicker, + }) { + return ActivityFormState( + weekDay: weekDay ?? this.weekDay, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + showStartPicker: showStartPicker ?? this.showStartPicker, + showEndPicker: showEndPicker ?? this.showEndPicker, + ); + } +} + +@riverpod +class ActivityForm extends _$ActivityForm { + @override + ActivityFormState build(ScheduledActivityEntity? initial, int fallbackWeekDay) { + if (initial != null && initial.hasValidPeriod) { + return ActivityFormState( + weekDay: initial.weekDay, + startTime: TimeOfDay( + hour: int.parse(initial.period.substring(0, 2)), + minute: int.parse(initial.period.substring(2, 4)), + ), + endTime: TimeOfDay( + hour: int.parse(initial.period.substring(4, 6)), + minute: int.parse(initial.period.substring(6, 8)), + ), + ); + } + return ActivityFormState( + weekDay: fallbackWeekDay, + startTime: const TimeOfDay(hour: 8, minute: 0), + endTime: const TimeOfDay(hour: 9, minute: 0), + ); + } + + void setWeekDay(int value) { + if (value == state.weekDay) return; + state = state.copyWith(weekDay: value); + } + + void setStartTime(TimeOfDay value) { + state = state.copyWith(startTime: value); + } + + void setEndTime(TimeOfDay value) { + state = state.copyWith(endTime: value); + } + + void toggleStartPicker() { + state = state.copyWith( + showStartPicker: !state.showStartPicker, + showEndPicker: false, + ); + } + + void toggleEndPicker() { + state = state.copyWith( + showEndPicker: !state.showEndPicker, + showStartPicker: false, + ); + } +} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.g.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.g.dart new file mode 100644 index 00000000..96bf575c --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/activity_form_provider.g.dart @@ -0,0 +1,114 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity_form_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ActivityForm) +const activityFormProvider = ActivityFormFamily._(); + +final class ActivityFormProvider + extends $NotifierProvider { + const ActivityFormProvider._({ + required ActivityFormFamily super.from, + required (ScheduledActivityEntity?, int) super.argument, + }) : super( + retry: null, + name: r'activityFormProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$activityFormHash(); + + @override + String toString() { + return r'activityFormProvider' + '' + '$argument'; + } + + @$internal + @override + ActivityForm create() => ActivityForm(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ActivityFormState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is ActivityFormProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$activityFormHash() => r'4b379b0412538ac5ac849c2e4fa4efa334dbdb3c'; + +final class ActivityFormFamily extends $Family + with + $ClassFamilyOverride< + ActivityForm, + ActivityFormState, + ActivityFormState, + ActivityFormState, + (ScheduledActivityEntity?, int) + > { + const ActivityFormFamily._() + : super( + retry: null, + name: r'activityFormProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ActivityFormProvider call( + ScheduledActivityEntity? initial, + int fallbackWeekDay, + ) => ActivityFormProvider._(argument: (initial, fallbackWeekDay), from: this); + + @override + String toString() => r'activityFormProvider'; +} + +abstract class _$ActivityForm extends $Notifier { + late final _$args = ref.$arg as (ScheduledActivityEntity?, int); + ScheduledActivityEntity? get initial => _$args.$1; + int get fallbackWeekDay => _$args.$2; + + ActivityFormState build( + ScheduledActivityEntity? initial, + int fallbackWeekDay, + ); + @$mustCallSuper + @override + void runBuild() { + final created = build(_$args.$1, _$args.$2); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ActivityFormState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart new file mode 100644 index 00000000..75bb30a7 --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:device_management/src/core/data/models/create_scheduled_activity_request_dto.dart'; +import 'package:device_management/src/core/data/models/update_scheduled_activity_request_dto.dart'; +import 'package:device_management/src/core/providers/scheduled_activities_repository_provider.dart'; +import 'package:device_management/src/features/scheduled_activities/domain/entities/scheduled_activity_entity.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sf_tracking/sf_tracking.dart'; +import 'package:uuid/uuid.dart'; + +part 'scheduled_activities_controller.g.dart'; + +class ScheduledActivityOverlapException implements Exception { + const ScheduledActivityOverlapException(this.overlap); + final ScheduledActivityEntity overlap; +} + +enum ScheduledActivityAction { create, update, delete } + +@riverpod +class ScheduledActivitiesController extends _$ScheduledActivitiesController { + static const _uuid = Uuid(); + + ScheduledActivityAction? _lastAction; + + ScheduledActivityAction? get lastAction => _lastAction; + + @override + FutureOr build() {} + + ScheduledActivityEntity? _findOverlap({ + required List current, + required int weekDay, + required String period, + String? excludeId, + }) { + final candidate = ScheduledActivityEntity( + id: '', + deviceId: '', + weekDay: weekDay, + name: '', + period: period, + createdAt: 0, + ); + for (final activity in current) { + if (activity.id == excludeId) continue; + if (activity.overlapsWith(candidate)) return activity; + } + return null; + } + + Future createActivity({ + required String deviceId, + required String name, + required int weekDay, + required String period, + required List current, + }) async { + _lastAction = ScheduledActivityAction.create; + state = const AsyncLoading(); + final overlap = + _findOverlap(current: current, weekDay: weekDay, period: period); + if (overlap != null) { + state = AsyncError( + ScheduledActivityOverlapException(overlap), + StackTrace.current, + ); + return; + } + state = await AsyncValue.guard(() async { + await ref.read(scheduledActivitiesRepositoryProvider).createActivity( + request: CreateScheduledActivityRequestDto( + id: _uuid.v4(), + deviceId: deviceId, + weekDay: weekDay, + name: name, + period: period, + ), + ); + ref.invalidate(scheduledActivitiesProvider(deviceId)); + unawaited( + ref + .read(sfTrackingProvider) + .legacyDeviceScheduledActivityAdded( + weekDay: weekDay, + period: period, + ), + ); + }); + } + + Future updateActivity({ + required String deviceId, + required String activityId, + required int weekDay, + required String name, + required String period, + required List current, + }) async { + _lastAction = ScheduledActivityAction.update; + state = const AsyncLoading(); + final overlap = _findOverlap( + current: current, + weekDay: weekDay, + period: period, + excludeId: activityId, + ); + if (overlap != null) { + state = AsyncError( + ScheduledActivityOverlapException(overlap), + StackTrace.current, + ); + return; + } + state = await AsyncValue.guard(() async { + await ref.read(scheduledActivitiesRepositoryProvider).updateActivity( + activityId: activityId, + request: UpdateScheduledActivityRequestDto( + deviceId: deviceId, + name: name, + period: period, + ), + ); + ref.invalidate(scheduledActivitiesProvider(deviceId)); + unawaited( + ref + .read(sfTrackingProvider) + .legacyDeviceScheduledActivityUpdated( + weekDay: weekDay, + period: period, + ), + ); + }); + } + + Future deleteActivity({ + required String deviceId, + required String activityId, + }) async { + _lastAction = ScheduledActivityAction.delete; + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + await ref + .read(scheduledActivitiesRepositoryProvider) + .deleteActivity(activityId: activityId); + ref.invalidate(scheduledActivitiesProvider(deviceId)); + unawaited( + ref.read(sfTrackingProvider).legacyDeviceScheduledActivityRemoved(), + ); + }); + } +} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.g.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.g.dart new file mode 100644 index 00000000..3c3b9a7b --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_activities_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ScheduledActivitiesController) +const scheduledActivitiesControllerProvider = + ScheduledActivitiesControllerProvider._(); + +final class ScheduledActivitiesControllerProvider + extends $AsyncNotifierProvider { + const ScheduledActivitiesControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'scheduledActivitiesControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$scheduledActivitiesControllerHash(); + + @$internal + @override + ScheduledActivitiesController create() => ScheduledActivitiesController(); +} + +String _$scheduledActivitiesControllerHash() => + r'0add05c19aae539637b858f13168a886879042c9'; + +abstract class _$ScheduledActivitiesController 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/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.dart new file mode 100644 index 00000000..f7029d7e --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.dart @@ -0,0 +1,20 @@ +import 'package:device_management/src/core/providers/scheduled_activities_repository_provider.dart'; +import 'package:device_management/src/features/scheduled_activities/domain/entities/scheduled_activity_entity.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'scheduled_activities_provider.g.dart'; + +@riverpod +Future> scheduledActivities( + Ref ref, + String deviceId, +) async { + final activities = await ref + .read(scheduledActivitiesRepositoryProvider) + .getActivities(deviceId: deviceId); + return [...activities]..sort((a, b) { + final dayCompare = a.weekDay.compareTo(b.weekDay); + if (dayCompare != 0) return dayCompare; + return a.startMinutes.compareTo(b.startMinutes); + }); +} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.g.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.g.dart new file mode 100644 index 00000000..2ded1e62 --- /dev/null +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.g.dart @@ -0,0 +1,92 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_activities_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(scheduledActivities) +const scheduledActivitiesProvider = ScheduledActivitiesFamily._(); + +final class ScheduledActivitiesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + const ScheduledActivitiesProvider._({ + required ScheduledActivitiesFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'scheduledActivitiesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$scheduledActivitiesHash(); + + @override + String toString() { + return r'scheduledActivitiesProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as String; + return scheduledActivities(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is ScheduledActivitiesProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$scheduledActivitiesHash() => + r'bf2e33cd408ea36967046d0c46323fc49ef628f1'; + +final class ScheduledActivitiesFamily extends $Family + with + $FunctionalFamilyOverride< + FutureOr>, + String + > { + const ScheduledActivitiesFamily._() + : super( + retry: null, + name: r'scheduledActivitiesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ScheduledActivitiesProvider call(String deviceId) => + ScheduledActivitiesProvider._(argument: deviceId, from: this); + + @override + String toString() => r'scheduledActivitiesProvider'; +} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/scheduled_activities_screen.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/scheduled_activities_screen.dart index 4dfdcff5..9245d354 100644 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/scheduled_activities_screen.dart +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/scheduled_activities_screen.dart @@ -1,17 +1,17 @@ -import 'package:design_system/design_system.dart'; -import 'package:legacy_theme/legacy_theme.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/widgets/activity_form_sheet.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/widgets/day_timeline.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_device_state/legacy_device_state.dart'; +import 'package:legacy_theme/legacy_theme.dart'; import 'package:legacy_ui/legacy_ui.dart'; import 'package:navigation/navigation.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/sf_shared.dart'; import 'package:utils/utils.dart'; -import 'state/scheduled_activities_view_model.dart'; -import 'widgets/activity_form_sheet.dart'; -import 'widgets/day_timeline.dart'; - class ScheduledActivitiesScreen extends ConsumerStatefulWidget { final NavigationContract navigationContract; @@ -46,60 +46,90 @@ class _ScheduledActivitiesScreenState @override Widget build(BuildContext context) { - final state = ref.watch(scheduledActivitiesViewModelProvider); + final primaryColor = context.sfColors.legacyPrimary; + final device = ref.watch(selectedDeviceProvider).value; - ref.listen( - scheduledActivitiesViewModelProvider.select((s) => s.errorMessage), - (previous, next) { - if (next.isNotEmpty) { - showTopSnackbar(context, message: next, type: MessageType.error); + ref.listen(scheduledActivitiesControllerProvider, (prev, next) async { + if (prev == null || !prev.isLoading || next.isLoading) return; + if (next.hasError) { + final error = next.error; + if (error is ScheduledActivityOverlapException) { + await showErrorDialog( + context, + I18n.scheduledActivityOverlap, + args: { + 'name': error.overlap.name, + 'time': '${error.overlap.startTime}-${error.overlap.endTime}', + }, + ); + } else { + await next.showErrorOn(context); } - }, - ); + return; + } + final action = ref + .read(scheduledActivitiesControllerProvider.notifier) + .lastAction; + final key = switch (action) { + ScheduledActivityAction.create => I18n.scheduledActivityCreated, + ScheduledActivityAction.update => I18n.scheduledActivityUpdated, + ScheduledActivityAction.delete => I18n.scheduledActivityDeleted, + null => I18n.deviceUpdatedSuccess, + }; + await showSuccessDialog(context, key); + }); + + if (device == null) { + return LegacyPageLayout( + title: context.translate(I18n.activityScheduleTitle), + body: const Center(child: CircularProgressIndicator()), + ); + } + + final activitiesAsync = + ref.watch(scheduledActivitiesProvider(device.id)); return LegacyPageLayout( title: context.translate(I18n.activityScheduleTitle), - body: state.isLoading - ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - TabBar( - controller: _tabController, - labelColor: context.sfColors.legacyPrimary, - unselectedLabelColor: - Theme.of(context).colorScheme.onSurface, - indicatorColor: context.sfColors.legacyPrimary, - indicatorWeight: 3, - labelStyle: TextStyle( - fontSize: SizeUtils.getByScreen(small: 13, big: 12), - fontWeight: FontWeight.w600, - ), - unselectedLabelStyle: TextStyle( - fontSize: SizeUtils.getByScreen(small: 13, big: 12), - fontWeight: FontWeight.w400, - ), - tabs: weekDayI18nKeys.entries - .map( - (e) => Tab(text: weekDayShortLabel(context, e.value)), - ) - .toList(), - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: List.generate(7, (index) { - final weekDay = index + 1; - final dayActivities = state.activities - .where((a) => a.weekDay == weekDay) - .toList(); - return DayTimeline(activities: dayActivities); - }), - ), - ), - ], + body: activitiesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center(child: Text(err.toString())), + data: (activities) => Column( + children: [ + TabBar( + controller: _tabController, + labelColor: primaryColor, + unselectedLabelColor: Theme.of(context).colorScheme.onSurface, + indicatorColor: primaryColor, + indicatorWeight: 3, + labelStyle: TextStyle( + fontSize: SizeUtils.getByScreen(small: 13, big: 12), + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: TextStyle( + fontSize: SizeUtils.getByScreen(small: 13, big: 12), + fontWeight: FontWeight.w400, + ), + tabs: weekDayI18nKeys.entries + .map((e) => Tab(text: weekDayShortLabel(context, e.value))) + .toList(), ), + Expanded( + child: TabBarView( + controller: _tabController, + children: List.generate(7, (index) { + final weekDay = index + 1; + final dayActivities = + activities.where((a) => a.weekDay == weekDay).toList(); + return DayTimeline(activities: dayActivities); + }), + ), + ), + ], + ), + ), footer: Material( - color: context.sfColors.legacyPrimary, + color: primaryColor, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart deleted file mode 100644 index 6386c826..00000000 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sf_shared/sf_shared.dart'; -import 'package:sf_localizations/sf_localizations.dart'; -import 'package:sf_tracking/sf_tracking.dart'; -import 'package:uuid/uuid.dart'; - -import '../../../../core/data/models/create_scheduled_activity_request_dto.dart'; -import '../../../../core/data/models/update_scheduled_activity_request_dto.dart'; -import '../../../../core/domain/repositories/scheduled_activities_repository.dart'; -import '../../../../core/providers/scheduled_activities_repository_provider.dart'; -import '../../domain/entities/scheduled_activity_entity.dart'; -import 'scheduled_activities_view_state.dart'; - -final scheduledActivitiesViewModelProvider = - NotifierProvider.autoDispose< - ScheduledActivitiesViewModel, - ScheduledActivitiesViewState - >(ScheduledActivitiesViewModel.new); - -class ScheduledActivitiesViewModel - extends Notifier { - late final ScheduledActivitiesRepository _repository; - late final SfTrackingRepository _tracking; - static const _uuid = Uuid(); - - @override - ScheduledActivitiesViewState build() { - _repository = ref.read(scheduledActivitiesRepositoryProvider); - _tracking = ref.read(sfTrackingProvider); - _init(); - return const ScheduledActivitiesViewState(); - } - - String? get _deviceId => ref.read(selectedDeviceProvider).value?.id; - - Future _init() async { - if (_deviceId == null) { - state = state.copyWith(isLoading: false); - return; - } - - try { - final activities = await _repository.getActivities(deviceId: _deviceId!); - if (!ref.mounted) return; - - state = state.copyWith( - activities: _sortActivities(activities), - isLoading: false, - ); - } catch (e) { - if (!ref.mounted) return; - state = state.copyWith(isLoading: false, errorMessage: _formatError(e)); - } - } - - ScheduledActivityEntity? _findOverlap({ - required int weekDay, - required String period, - String? excludeId, - }) { - final candidate = ScheduledActivityEntity( - id: '', - deviceId: '', - weekDay: weekDay, - name: '', - period: period, - createdAt: 0, - ); - - for (final activity in state.activities) { - if (activity.id == excludeId) continue; - if (activity.overlapsWith(candidate)) return activity; - } - return null; - } - - Future createActivity({ - required String name, - required int weekDay, - required String period, - }) async { - if (_deviceId == null) return; - - final overlap = _findOverlap(weekDay: weekDay, period: period); - if (overlap != null) { - state = state.copyWith(errorMessage: _overlapMessage(overlap)); - return; - } - - try { - state = state.copyWith(isLoading: true, errorMessage: ''); - - final request = CreateScheduledActivityRequestDto( - id: _uuid.v4(), - deviceId: _deviceId!, - weekDay: weekDay, - name: name, - period: period, - ); - - await _repository.createActivity(request: request); - if (!ref.mounted) return; - - unawaited( - _tracking.legacyDeviceScheduledActivityAdded( - weekDay: weekDay, - period: period, - ), - ); - - await _reload(); - } catch (e) { - if (!ref.mounted) return; - state = state.copyWith(isLoading: false, errorMessage: _formatError(e)); - } - } - - Future updateActivity({ - required String activityId, - required int weekDay, - required String name, - required String period, - }) async { - if (_deviceId == null) return; - - final overlap = _findOverlap( - weekDay: weekDay, - period: period, - excludeId: activityId, - ); - if (overlap != null) { - state = state.copyWith(errorMessage: _overlapMessage(overlap)); - return; - } - - try { - state = state.copyWith(isLoading: true, errorMessage: ''); - - final request = UpdateScheduledActivityRequestDto( - deviceId: _deviceId!, - name: name, - period: period, - ); - - await _repository.updateActivity( - activityId: activityId, - request: request, - ); - if (!ref.mounted) return; - - unawaited( - _tracking.legacyDeviceScheduledActivityUpdated( - weekDay: weekDay, - period: period, - ), - ); - - await _reload(); - } catch (e) { - if (!ref.mounted) return; - state = state.copyWith(isLoading: false, errorMessage: _formatError(e)); - } - } - - Future deleteActivity(String activityId) async { - try { - state = state.copyWith(isLoading: true, errorMessage: ''); - - await _repository.deleteActivity(activityId: activityId); - if (!ref.mounted) return; - - unawaited(_tracking.legacyDeviceScheduledActivityRemoved()); - - await _reload(); - } catch (e) { - if (!ref.mounted) return; - state = state.copyWith(isLoading: false, errorMessage: _formatError(e)); - } - } - - Future _reload() async { - if (_deviceId == null) return; - - try { - final activities = await _repository.getActivities(deviceId: _deviceId!); - if (!ref.mounted) return; - - state = state.copyWith( - activities: _sortActivities(activities), - isLoading: false, - ); - } catch (e) { - if (!ref.mounted) return; - state = state.copyWith(isLoading: false, errorMessage: _formatError(e)); - } - } - - List _sortActivities( - List activities, - ) { - return List.of(activities)..sort((a, b) { - final dayCompare = a.weekDay.compareTo(b.weekDay); - if (dayCompare != 0) return dayCompare; - return a.startMinutes.compareTo(b.startMinutes); - }); - } - - String _overlapMessage(ScheduledActivityEntity overlap) { - return I18n.scheduledActivityOverlap.tr( - args: { - 'name': overlap.name, - 'time': '${overlap.startTime}-${overlap.endTime}', - }, - ); - } - - String _formatError(Object e) { - final msg = e.toString(); - return msg.startsWith('Exception: ') ? msg.substring(11) : msg; - } -} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_state.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_state.dart deleted file mode 100644 index 1ea2d693..00000000 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_state.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:device_management/src/features/scheduled_activities/domain/entities/scheduled_activity_entity.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'scheduled_activities_view_state.freezed.dart'; - -@freezed -abstract class ScheduledActivitiesViewState - with _$ScheduledActivitiesViewState { - const factory ScheduledActivitiesViewState({ - @Default([]) List activities, - @Default(true) bool isLoading, - @Default('') String errorMessage, - }) = _ScheduledActivitiesViewState; -} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_state.freezed.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_state.freezed.dart deleted file mode 100644 index 79228772..00000000 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_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 'scheduled_activities_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$ScheduledActivitiesViewState { - - List get activities; bool get isLoading; String get errorMessage; -/// Create a copy of ScheduledActivitiesViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$ScheduledActivitiesViewStateCopyWith get copyWith => _$ScheduledActivitiesViewStateCopyWithImpl(this as ScheduledActivitiesViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is ScheduledActivitiesViewState&&const DeepCollectionEquality().equals(other.activities, activities)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(activities),isLoading,errorMessage); - -@override -String toString() { - return 'ScheduledActivitiesViewState(activities: $activities, isLoading: $isLoading, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class $ScheduledActivitiesViewStateCopyWith<$Res> { - factory $ScheduledActivitiesViewStateCopyWith(ScheduledActivitiesViewState value, $Res Function(ScheduledActivitiesViewState) _then) = _$ScheduledActivitiesViewStateCopyWithImpl; -@useResult -$Res call({ - List activities, bool isLoading, String errorMessage -}); - - - - -} -/// @nodoc -class _$ScheduledActivitiesViewStateCopyWithImpl<$Res> - implements $ScheduledActivitiesViewStateCopyWith<$Res> { - _$ScheduledActivitiesViewStateCopyWithImpl(this._self, this._then); - - final ScheduledActivitiesViewState _self; - final $Res Function(ScheduledActivitiesViewState) _then; - -/// Create a copy of ScheduledActivitiesViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? activities = null,Object? isLoading = null,Object? errorMessage = null,}) { - return _then(_self.copyWith( -activities: null == activities ? _self.activities : activities // ignore: cast_nullable_to_non_nullable -as List,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 [ScheduledActivitiesViewState]. -extension ScheduledActivitiesViewStatePatterns on ScheduledActivitiesViewState { -/// 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( _ScheduledActivitiesViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _ScheduledActivitiesViewState() 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( _ScheduledActivitiesViewState value) $default,){ -final _that = this; -switch (_that) { -case _ScheduledActivitiesViewState(): -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( _ScheduledActivitiesViewState value)? $default,){ -final _that = this; -switch (_that) { -case _ScheduledActivitiesViewState() 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 activities, bool isLoading, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _ScheduledActivitiesViewState() when $default != null: -return $default(_that.activities,_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( List activities, bool isLoading, String errorMessage) $default,) {final _that = this; -switch (_that) { -case _ScheduledActivitiesViewState(): -return $default(_that.activities,_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( List activities, bool isLoading, String errorMessage)? $default,) {final _that = this; -switch (_that) { -case _ScheduledActivitiesViewState() when $default != null: -return $default(_that.activities,_that.isLoading,_that.errorMessage);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _ScheduledActivitiesViewState implements ScheduledActivitiesViewState { - const _ScheduledActivitiesViewState({final List activities = const [], this.isLoading = true, this.errorMessage = ''}): _activities = activities; - - - final List _activities; -@override@JsonKey() List get activities { - if (_activities is EqualUnmodifiableListView) return _activities; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_activities); -} - -@override@JsonKey() final bool isLoading; -@override@JsonKey() final String errorMessage; - -/// Create a copy of ScheduledActivitiesViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$ScheduledActivitiesViewStateCopyWith<_ScheduledActivitiesViewState> get copyWith => __$ScheduledActivitiesViewStateCopyWithImpl<_ScheduledActivitiesViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScheduledActivitiesViewState&&const DeepCollectionEquality().equals(other._activities, _activities)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); -} - - -@override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_activities),isLoading,errorMessage); - -@override -String toString() { - return 'ScheduledActivitiesViewState(activities: $activities, isLoading: $isLoading, errorMessage: $errorMessage)'; -} - - -} - -/// @nodoc -abstract mixin class _$ScheduledActivitiesViewStateCopyWith<$Res> implements $ScheduledActivitiesViewStateCopyWith<$Res> { - factory _$ScheduledActivitiesViewStateCopyWith(_ScheduledActivitiesViewState value, $Res Function(_ScheduledActivitiesViewState) _then) = __$ScheduledActivitiesViewStateCopyWithImpl; -@override @useResult -$Res call({ - List activities, bool isLoading, String errorMessage -}); - - - - -} -/// @nodoc -class __$ScheduledActivitiesViewStateCopyWithImpl<$Res> - implements _$ScheduledActivitiesViewStateCopyWith<$Res> { - __$ScheduledActivitiesViewStateCopyWithImpl(this._self, this._then); - - final _ScheduledActivitiesViewState _self; - final $Res Function(_ScheduledActivitiesViewState) _then; - -/// Create a copy of ScheduledActivitiesViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? activities = null,Object? isLoading = null,Object? errorMessage = null,}) { - return _then(_ScheduledActivitiesViewState( -activities: null == activities ? _self._activities : activities // ignore: cast_nullable_to_non_nullable -as List,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/device_management/lib/src/features/scheduled_activities/presentation/widgets/activity_form_sheet.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/activity_form_sheet.dart index ff3e5175..dcfa7ca4 100644 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/activity_form_sheet.dart +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/activity_form_sheet.dart @@ -1,21 +1,23 @@ -import 'package:legacy_theme/legacy_theme.dart'; +import 'package:device_management/src/features/scheduled_activities/domain/entities/scheduled_activity_entity.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/activity_form_provider.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/scheduled_activities_provider.dart'; import 'package:flutter/cupertino.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:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/sf_shared.dart'; import 'package:utils/utils.dart'; -import '../../domain/entities/scheduled_activity_entity.dart'; -import '../state/scheduled_activities_view_model.dart'; - void showActivityFormSheet( BuildContext context, { ScheduledActivityEntity? activity, int? weekDay, }) { - showModalBottomSheet( + showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, @@ -35,94 +37,82 @@ class ActivityFormSheet extends ConsumerStatefulWidget { class _ActivityFormSheetState extends ConsumerState { late final TextEditingController _nameController; - late int _selectedWeekDay; - late TimeOfDay _startTime; - late TimeOfDay _endTime; - bool _showStartPicker = false; - bool _showEndPicker = false; bool get _isEditing => widget.activity != null; - bool get _isFormValid { - if (_nameController.text.trim().isEmpty) return false; - return _timeToMinutes(_startTime) < _timeToMinutes(_endTime); - } - @override void initState() { super.initState(); - final activity = widget.activity; - _nameController = TextEditingController(text: activity?.name ?? ''); - _nameController.addListener(() => setState(() {})); - _selectedWeekDay = activity?.weekDay ?? widget.weekDay ?? 1; - - if (activity != null && activity.hasValidPeriod) { - _startTime = TimeOfDay( - hour: int.parse(activity.period.substring(0, 2)), - minute: int.parse(activity.period.substring(2, 4)), - ); - _endTime = TimeOfDay( - hour: int.parse(activity.period.substring(4, 6)), - minute: int.parse(activity.period.substring(6, 8)), - ); - } else { - _startTime = const TimeOfDay(hour: 8, minute: 0); - _endTime = const TimeOfDay(hour: 9, minute: 0); - } + _nameController = TextEditingController(text: widget.activity?.name ?? ''); + _nameController.addListener(_onNameChanged); } @override void dispose() { + _nameController.removeListener(_onNameChanged); _nameController.dispose(); super.dispose(); } - String _buildPeriod() { - return '${_startTime.hour.toString().padLeft(2, '0')}' - '${_startTime.minute.toString().padLeft(2, '0')}' - '${_endTime.hour.toString().padLeft(2, '0')}' - '${_endTime.minute.toString().padLeft(2, '0')}'; + void _onNameChanged() { + if (mounted) setState(() {}); } - int _timeToMinutes(TimeOfDay time) => time.hour * 60 + time.minute; - - void _togglePicker({required bool isStart}) { - setState(() { - if (isStart) { - _showStartPicker = !_showStartPicker; - _showEndPicker = false; - } else { - _showEndPicker = !_showEndPicker; - _showStartPicker = false; - } - }); - } - - Future _submit() async { - if (!_isFormValid) return; + Future _submit({ + required ActivityFormState formState, + required List currentActivities, + }) async { + if (!formState.isTimeValid) return; + if (_nameController.text.trim().isEmpty) return; if (!await guardDeviceCommand(context, ref)) return; if (!mounted) return; + final device = ref.read(selectedDeviceProvider).value; + if (device == null) return; + final name = _nameController.text.trim(); - final period = _buildPeriod(); - final vm = ref.read(scheduledActivitiesViewModelProvider.notifier); + final controller = + ref.read(scheduledActivitiesControllerProvider.notifier); + + Navigator.of(context).pop(); if (_isEditing) { - vm.updateActivity( + controller.updateActivity( + deviceId: device.id, activityId: widget.activity!.id, weekDay: widget.activity!.weekDay, name: name, - period: period, + period: formState.period, + current: currentActivities, ); } else { - vm.createActivity(name: name, weekDay: _selectedWeekDay, period: period); + controller.createActivity( + deviceId: device.id, + name: name, + weekDay: formState.weekDay, + period: formState.period, + current: currentActivities, + ); } - - Navigator.of(context).pop(); } @override Widget build(BuildContext context) { + final primaryColor = context.sfColors.legacyPrimary; + final fallbackWeekDay = widget.weekDay ?? 1; + final formProvider = activityFormProvider(widget.activity, fallbackWeekDay); + final formState = ref.watch(formProvider); + final formNotifier = ref.read(formProvider.notifier); + + final device = ref.watch(selectedDeviceProvider).value; + final currentActivities = device == null + ? const [] + : (ref.watch(scheduledActivitiesProvider(device.id)).value ?? + const []); + + final canSubmit = _nameController.text.trim().isNotEmpty && + formState.isTimeValid; + final bottomInset = MediaQuery.of(context).viewInsets.bottom; return Padding( @@ -136,7 +126,8 @@ class _ActivityFormSheetState extends ConsumerState { ), child: SafeArea( child: SingleChildScrollView( - padding: EdgeInsets.all(SizeUtils.getByScreen(small: 22, big: 20)), + padding: + EdgeInsets.all(SizeUtils.getByScreen(small: 22, big: 20)), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -163,7 +154,7 @@ class _ActivityFormSheetState extends ConsumerState { style: TextStyle( fontSize: SizeUtils.getByScreen(small: 20, big: 19), fontWeight: FontWeight.bold, - color: context.sfColors.legacyPrimary, + color: primaryColor, ), ), SizedBox(height: SizeUtils.getByScreen(small: 20, big: 18)), @@ -176,12 +167,9 @@ class _ActivityFormSheetState extends ConsumerState { ), SizedBox(height: SizeUtils.getByScreen(small: 16, big: 14)), WeekDayChips.single( - selectedWeekDay: _isEditing - ? widget.activity!.weekDay - : _selectedWeekDay, + selectedWeekDay: formState.weekDay, enabled: !_isEditing, - onChanged: (value) => - setState(() => _selectedWeekDay = value), + onChanged: formNotifier.setWeekDay, ), SizedBox(height: SizeUtils.getByScreen(small: 16, big: 14)), Row( @@ -191,39 +179,45 @@ class _ActivityFormSheetState extends ConsumerState { label: context.translate( I18n.scheduledActivityStartTime, ), - time: _startTime, - isExpanded: _showStartPicker, - onTap: () => _togglePicker(isStart: true), + time: formState.startTime, + isExpanded: formState.showStartPicker, + onTap: formNotifier.toggleStartPicker, ), ), SizedBox(width: SizeUtils.getByScreen(small: 12, big: 10)), Expanded( child: _TimeSelector( - label: context.translate(I18n.scheduledActivityEndTime), - time: _endTime, - isExpanded: _showEndPicker, - onTap: () => _togglePicker(isStart: false), + label: context + .translate(I18n.scheduledActivityEndTime), + time: formState.endTime, + isExpanded: formState.showEndPicker, + onTap: formNotifier.toggleEndPicker, ), ), ], ), - if (_showStartPicker) + if (formState.showStartPicker) _InlineTimePicker( - initialTime: _startTime, - onChanged: (time) => setState(() => _startTime = time), + initialTime: formState.startTime, + onChanged: formNotifier.setStartTime, ), - if (_showEndPicker) + if (formState.showEndPicker) _InlineTimePicker( - initialTime: _endTime, - onChanged: (time) => setState(() => _endTime = time), + initialTime: formState.endTime, + onChanged: formNotifier.setEndTime, ), SizedBox(height: SizeUtils.getByScreen(small: 24, big: 22)), SizedBox( height: SizeUtils.getByScreen(small: 48, big: 46), child: ElevatedButton( - onPressed: _isFormValid ? _submit : null, + onPressed: canSubmit + ? () => _submit( + formState: formState, + currentActivities: currentActivities, + ) + : null, style: ElevatedButton.styleFrom( - backgroundColor: context.sfColors.legacyPrimary, + backgroundColor: primaryColor, disabledBackgroundColor: Colors.grey[300], shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -234,7 +228,7 @@ class _ActivityFormSheetState extends ConsumerState { child: Text( context.translate(I18n.save), style: TextStyle( - color: _isFormValid ? Colors.white : Colors.grey, + color: canSubmit ? Colors.white : Colors.grey, fontSize: SizeUtils.getByScreen(small: 16, big: 15), fontWeight: FontWeight.w600, ), @@ -323,7 +317,10 @@ class _InlineTimePicker extends StatelessWidget { final TimeOfDay initialTime; final ValueChanged onChanged; - const _InlineTimePicker({required this.initialTime, required this.onChanged}); + const _InlineTimePicker({ + required this.initialTime, + required this.onChanged, + }); @override Widget build(BuildContext context) { diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/confirm_delete_activity_dialog.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/confirm_delete_activity_dialog.dart deleted file mode 100644 index fb321fd0..00000000 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/confirm_delete_activity_dialog.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sf_localizations/sf_localizations.dart'; -import 'package:utils/utils.dart'; - -import '../../domain/entities/scheduled_activity_entity.dart'; -import '../state/scheduled_activities_view_model.dart'; -import 'package:legacy_theme/legacy_theme.dart'; - -class ConfirmDeleteActivityDialog extends ConsumerWidget { - final ScheduledActivityEntity activity; - - const ConfirmDeleteActivityDialog({super.key, required this.activity}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - - return Container( - padding: SizeUtils.getByScreen( - small: const EdgeInsets.symmetric(horizontal: 32, vertical: 30), - big: const EdgeInsets.symmetric(horizontal: 30, vertical: 28), - ), - width: SizeUtils.getByScreen(small: 360, big: 350), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular( - SizeUtils.getByScreen(small: 16, big: 14), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - I18n.scheduledActivityDeleteMessage.tr( - args: {'name': activity.name}, - ), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: SizeUtils.getByScreen(small: 19, big: 18), - ), - ), - SizedBox(height: SizeUtils.getByScreen(small: 28, big: 27)), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: PrimaryButton( - onPressed: () => Navigator.pop(context), - text: context.translate(I18n.cancel), - color: context.sfColors.legacyPrimary, - height: SizeUtils.getByScreen(small: 38, big: 36), - radius: SizeUtils.getByScreen(small: 32, big: 34), - ), - ), - SizedBox(width: SizeUtils.getByScreen(small: 4, big: 16)), - Expanded( - child: PrimaryButton( - onPressed: () { - Navigator.pop(context); - ref - .read(scheduledActivitiesViewModelProvider.notifier) - .deleteActivity(activity.id); - }, - text: context.translate(I18n.delete), - color: Theme.of(context).colorScheme.error, - height: SizeUtils.getByScreen(small: 38, big: 36), - radius: SizeUtils.getByScreen(small: 32, big: 34), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/day_timeline.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/day_timeline.dart index cf80b4d8..6dfbab84 100644 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/day_timeline.dart +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/widgets/day_timeline.dart @@ -1,15 +1,15 @@ import 'package:design_system/design_system.dart'; -import 'package:legacy_theme/legacy_theme.dart'; +import 'package:device_management/src/features/scheduled_activities/domain/entities/scheduled_activity_entity.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/widgets/activity_form_sheet.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:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/sf_shared.dart'; import 'package:utils/utils.dart'; -import '../../domain/entities/scheduled_activity_entity.dart'; -import 'activity_form_sheet.dart'; -import 'confirm_delete_activity_dialog.dart'; - class DayTimeline extends ConsumerWidget { final List activities; @@ -17,7 +17,6 @@ class DayTimeline extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - if (activities.isEmpty) { return Center( child: Column( @@ -70,9 +69,7 @@ class DayTimeline extends ConsumerWidget { ), _TimelineDotAndLine(isLast: isLast, color: primaryColor), SizedBox(width: SizeUtils.getByScreen(small: 10, big: 8)), - Expanded( - child: _ActivityTimelineCard(activity: activity), - ), + Expanded(child: _ActivityTimelineCard(activity: activity)), ], ), ); @@ -85,10 +82,7 @@ class _TimeLabels extends StatelessWidget { final String startTime; final String endTime; - const _TimeLabels({ - required this.startTime, - required this.endTime, - }); + const _TimeLabels({required this.startTime, required this.endTime}); @override Widget build(BuildContext context) { @@ -105,7 +99,7 @@ class _TimeLabels extends StatelessWidget { children: [ SizedBox(height: SizeUtils.getByScreen(small: 10, big: 8)), Text(startTime, style: style), - Spacer(), + const Spacer(), Padding( padding: EdgeInsets.only( bottom: SizeUtils.getByScreen(small: 14, big: 12), @@ -113,7 +107,9 @@ class _TimeLabels extends StatelessWidget { child: Text( endTime, style: style.copyWith( - color: Theme.of(context).colorScheme.onSurface + color: Theme.of(context) + .colorScheme + .onSurface .withValues(alpha: 0.5), ), ), @@ -160,7 +156,8 @@ class _ActivityTimelineCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Container( - margin: EdgeInsets.only(bottom: SizeUtils.getByScreen(small: 10, big: 8)), + margin: + EdgeInsets.only(bottom: SizeUtils.getByScreen(small: 10, big: 8)), padding: SizeUtils.getByScreen( small: const EdgeInsets.fromLTRB(14, 10, 6, 10), big: const EdgeInsets.fromLTRB(12, 8, 4, 8), @@ -193,20 +190,47 @@ class _ActivityTimelineCard extends ConsumerWidget { color: context.sfColors.legacyPrimary, size: SizeUtils.getByScreen(small: 20, big: 18), ), - constraints: BoxConstraints(), + constraints: const BoxConstraints(), padding: EdgeInsets.all(SizeUtils.getByScreen(small: 8, big: 6)), ), IconButton( onPressed: () async { if (!await guardDeviceCommand(context, ref)) return; if (!context.mounted) return; - showDialog( + final confirmed = await showDialog( context: context, - builder: (_) => Dialog( - backgroundColor: Colors.transparent, - child: ConfirmDeleteActivityDialog(activity: activity), + builder: (dialogContext) => AlertDialog( + title: Text( + context.translate( + I18n.scheduledActivityDeleteMessage, + args: {'name': activity.name}, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.translate(I18n.cancel)), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: FilledButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.error, + ), + child: Text(context.translate(I18n.delete)), + ), + ], ), ); + if (confirmed != true) return; + final device = ref.read(selectedDeviceProvider).value; + if (device == null) return; + ref + .read(scheduledActivitiesControllerProvider.notifier) + .deleteActivity( + deviceId: device.id, + activityId: activity.id, + ); }, icon: Icon( Icons.delete_outlined, diff --git a/modules/legacy/modules/device_management/test/features/scheduled_activities/scheduled_activities_controller_test.dart b/modules/legacy/modules/device_management/test/features/scheduled_activities/scheduled_activities_controller_test.dart new file mode 100644 index 00000000..4000a774 --- /dev/null +++ b/modules/legacy/modules/device_management/test/features/scheduled_activities/scheduled_activities_controller_test.dart @@ -0,0 +1,177 @@ +import 'package:device_management/src/core/data/models/create_scheduled_activity_request_dto.dart'; +import 'package:device_management/src/core/data/models/update_scheduled_activity_request_dto.dart'; +import 'package:device_management/src/core/domain/repositories/scheduled_activities_repository.dart'; +import 'package:device_management/src/core/providers/scheduled_activities_repository_provider.dart'; +import 'package:device_management/src/features/scheduled_activities/domain/entities/scheduled_activity_entity.dart'; +import 'package:device_management/src/features/scheduled_activities/presentation/providers/scheduled_activities_controller.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:sf_infrastructure/sf_infrastructure.dart'; +import 'package:sf_shared/testing.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +class MockScheduledActivitiesRepository extends Mock + implements ScheduledActivitiesRepository {} + +const _a = ScheduledActivityEntity( + id: 'a', + deviceId: 'd1', + weekDay: 1, + name: 'English class', + period: '08000900', + createdAt: 0, +); + +void main() { + setUpAll(() { + registerFallbackValue( + const CreateScheduledActivityRequestDto( + id: 'x', + deviceId: 'd', + weekDay: 1, + name: 'n', + period: 'p', + ), + ); + registerFallbackValue( + const UpdateScheduledActivityRequestDto( + deviceId: 'd', + name: 'n', + period: 'p', + ), + ); + }); + + ProviderContainer buildContainer(ScheduledActivitiesRepository repo) { + return makeContainer( + overrides: [ + scheduledActivitiesRepositoryProvider.overrideWithValue(repo), + sfTrackingProvider.overrideWithValue( + SfTrackingRepository(clients: const []), + ), + ], + ); + } + + group('ScheduledActivitiesController.createActivity', () { + test('blocks on overlap', () async { + final repo = MockScheduledActivitiesRepository(); + final container = buildContainer(repo); + addTearDown(container.dispose); + + await container + .read(scheduledActivitiesControllerProvider.notifier) + .createActivity( + deviceId: 'd1', + name: 'Math', + weekDay: 1, + period: '08300930', + current: const [_a], + ); + + final state = container.read(scheduledActivitiesControllerProvider); + expect(state, isA>()); + expect(state.error, isA()); + verifyNever(() => repo.createActivity(request: any(named: 'request'))); + }); + + test('creates when no overlap', () async { + final repo = MockScheduledActivitiesRepository(); + when(() => repo.createActivity(request: any(named: 'request'))) + .thenAnswer((_) async {}); + + final container = buildContainer(repo); + addTearDown(container.dispose); + + await container + .read(scheduledActivitiesControllerProvider.notifier) + .createActivity( + deviceId: 'd1', + name: 'Math', + weekDay: 1, + period: '10001100', + current: const [_a], + ); + + expect( + container.read(scheduledActivitiesControllerProvider), + isA>(), + ); + verify(() => repo.createActivity(request: any(named: 'request'))) + .called(1); + }); + }); + + group('ScheduledActivitiesController.updateActivity', () { + test('allows updating same activity (excluded from overlap)', () async { + final repo = MockScheduledActivitiesRepository(); + when(() => repo.updateActivity( + activityId: any(named: 'activityId'), + request: any(named: 'request'), + )).thenAnswer((_) async {}); + + final container = buildContainer(repo); + addTearDown(container.dispose); + + await container + .read(scheduledActivitiesControllerProvider.notifier) + .updateActivity( + deviceId: 'd1', + activityId: 'a', + weekDay: 1, + name: 'English rename', + period: '08000900', + current: const [_a], + ); + + expect( + container.read(scheduledActivitiesControllerProvider), + isA>(), + ); + verify(() => repo.updateActivity( + activityId: 'a', + request: any(named: 'request'), + )).called(1); + }); + }); + + group('ScheduledActivitiesController.deleteActivity', () { + test('deletes via repository on success', () async { + final repo = MockScheduledActivitiesRepository(); + when(() => repo.deleteActivity(activityId: any(named: 'activityId'))) + .thenAnswer((_) async {}); + + final container = buildContainer(repo); + addTearDown(container.dispose); + + await container + .read(scheduledActivitiesControllerProvider.notifier) + .deleteActivity(deviceId: 'd1', activityId: 'a'); + + expect( + container.read(scheduledActivitiesControllerProvider), + isA>(), + ); + verify(() => repo.deleteActivity(activityId: 'a')).called(1); + }); + + test('exposes AsyncError when repository fails', () async { + final repo = MockScheduledActivitiesRepository(); + when(() => repo.deleteActivity(activityId: any(named: 'activityId'))) + .thenThrow(const ApiException(message: 'boom', isNetworkError: true)); + + final container = buildContainer(repo); + addTearDown(container.dispose); + + await container + .read(scheduledActivitiesControllerProvider.notifier) + .deleteActivity(deviceId: 'd1', activityId: 'a'); + + expect( + container.read(scheduledActivitiesControllerProvider), + isA>(), + ); + }); + }); +} diff --git a/packages/sf_localizations/assets/l10n/de.json b/packages/sf_localizations/assets/l10n/de.json index 48bac5ed..277a2f95 100644 --- a/packages/sf_localizations/assets/l10n/de.json +++ b/packages/sf_localizations/assets/l10n/de.json @@ -529,6 +529,9 @@ "scheduledActivityEndTime": "Ende", "scheduledActivityStartBeforeEnd": "Die Startzeit muss vor der Endzeit liegen", "scheduledActivityOverlap": "Überschneidung mit „{name}\" ({time})", + "scheduledActivityCreated": "Aktivität erfolgreich erstellt", + "scheduledActivityUpdated": "Aktivität erfolgreich aktualisiert", + "scheduledActivityDeleted": "Aktivität erfolgreich gelöscht", "scheduledActivityNewTitle": "Neue Aktivität", "scheduledActivityEditTitle": "Aktivität bearbeiten", "scheduledActivityDeleteTitle": "Aktivität löschen", diff --git a/packages/sf_localizations/assets/l10n/en.json b/packages/sf_localizations/assets/l10n/en.json index 95648f3e..50e103a0 100755 --- a/packages/sf_localizations/assets/l10n/en.json +++ b/packages/sf_localizations/assets/l10n/en.json @@ -726,6 +726,9 @@ "scheduledActivityEndTime": "End", "scheduledActivityStartBeforeEnd": "Start time must be before end time", "scheduledActivityOverlap": "Overlaps with \"{name}\" ({time})", + "scheduledActivityCreated": "Activity created successfully", + "scheduledActivityUpdated": "Activity updated successfully", + "scheduledActivityDeleted": "Activity deleted successfully", "scheduledActivityNewTitle": "New activity", "scheduledActivityEditTitle": "Edit activity", "scheduledActivityDeleteTitle": "Delete activity", diff --git a/packages/sf_localizations/assets/l10n/es.json b/packages/sf_localizations/assets/l10n/es.json index e573b52a..381dc0d5 100644 --- a/packages/sf_localizations/assets/l10n/es.json +++ b/packages/sf_localizations/assets/l10n/es.json @@ -727,6 +727,9 @@ "scheduledActivityEndTime": "Fin", "scheduledActivityStartBeforeEnd": "La hora de inicio debe ser anterior a la hora de fin", "scheduledActivityOverlap": "Se superpone con \"{name}\" ({time})", + "scheduledActivityCreated": "Actividad creada correctamente", + "scheduledActivityUpdated": "Actividad actualizada correctamente", + "scheduledActivityDeleted": "Actividad eliminada correctamente", "scheduledActivityNewTitle": "Nueva actividad", "scheduledActivityEditTitle": "Editar actividad", "scheduledActivityDeleteTitle": "Eliminar actividad", diff --git a/packages/sf_localizations/assets/l10n/fr.json b/packages/sf_localizations/assets/l10n/fr.json index 7e68a462..96afedd0 100644 --- a/packages/sf_localizations/assets/l10n/fr.json +++ b/packages/sf_localizations/assets/l10n/fr.json @@ -529,6 +529,9 @@ "scheduledActivityEndTime": "Fin", "scheduledActivityStartBeforeEnd": "L'heure de début doit être antérieure à l'heure de fin", "scheduledActivityOverlap": "Chevauche « {name} » ({time})", + "scheduledActivityCreated": "Activité créée avec succès", + "scheduledActivityUpdated": "Activité mise à jour avec succès", + "scheduledActivityDeleted": "Activité supprimée avec succès", "scheduledActivityNewTitle": "Nouvelle activité", "scheduledActivityEditTitle": "Modifier l'activité", "scheduledActivityDeleteTitle": "Supprimer l'activité", diff --git a/packages/sf_localizations/assets/l10n/it.json b/packages/sf_localizations/assets/l10n/it.json index c2025558..6f35e4d8 100644 --- a/packages/sf_localizations/assets/l10n/it.json +++ b/packages/sf_localizations/assets/l10n/it.json @@ -529,6 +529,9 @@ "scheduledActivityEndTime": "Fine", "scheduledActivityStartBeforeEnd": "L'orario di inizio deve essere precedente all'orario di fine", "scheduledActivityOverlap": "Si sovrappone con \"{name}\" ({time})", + "scheduledActivityCreated": "Attività creata con successo", + "scheduledActivityUpdated": "Attività aggiornata con successo", + "scheduledActivityDeleted": "Attività eliminata con successo", "scheduledActivityNewTitle": "Nuova attività", "scheduledActivityEditTitle": "Modifica attività", "scheduledActivityDeleteTitle": "Elimina attività", diff --git a/packages/sf_localizations/assets/l10n/pt.json b/packages/sf_localizations/assets/l10n/pt.json index da1989da..1fd5409f 100644 --- a/packages/sf_localizations/assets/l10n/pt.json +++ b/packages/sf_localizations/assets/l10n/pt.json @@ -529,6 +529,9 @@ "scheduledActivityEndTime": "Fim", "scheduledActivityStartBeforeEnd": "A hora de início deve ser anterior à hora de fim", "scheduledActivityOverlap": "Sobrepõe-se com \"{name}\" ({time})", + "scheduledActivityCreated": "Atividade criada com sucesso", + "scheduledActivityUpdated": "Atividade atualizada com sucesso", + "scheduledActivityDeleted": "Atividade eliminada com sucesso", "scheduledActivityNewTitle": "Nova atividade", "scheduledActivityEditTitle": "Editar atividade", "scheduledActivityDeleteTitle": "Eliminar atividade", diff --git a/packages/sf_localizations/lib/src/generated/i18n.dart b/packages/sf_localizations/lib/src/generated/i18n.dart index 8d7f201d..89bea2c6 100755 --- a/packages/sf_localizations/lib/src/generated/i18n.dart +++ b/packages/sf_localizations/lib/src/generated/i18n.dart @@ -819,9 +819,12 @@ class I18n { static const String scheduledActivityEditTitle = 'scheduledActivityEditTitle'; static const String scheduledActivityEmpty = 'scheduledActivityEmpty'; static const String scheduledActivityEmptyHint = 'scheduledActivityEmptyHint'; + static const String scheduledActivityCreated = 'scheduledActivityCreated'; + static const String scheduledActivityDeleted = 'scheduledActivityDeleted'; static const String scheduledActivityEndTime = 'scheduledActivityEndTime'; static const String scheduledActivityNewTitle = 'scheduledActivityNewTitle'; static const String scheduledActivityOverlap = 'scheduledActivityOverlap'; + static const String scheduledActivityUpdated = 'scheduledActivityUpdated'; static const String scheduledActivityStartBeforeEnd = 'scheduledActivityStartBeforeEnd'; static const String scheduledActivityStartTime = 'scheduledActivityStartTime'; static const String secretCodeConfigure = 'secretCodeConfigure';