refactor(device_management): migrate scheduled_activities to Riverpod

This commit is contained in:
2026-04-22 22:35:27 +02:00
parent a181ae4724
commit ba76348936
21 changed files with 945 additions and 756 deletions

View File

@@ -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,
);
}
}

View File

@@ -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<ActivityForm, ActivityFormState> {
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<ActivityFormState>(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<ActivityFormState> {
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<ActivityFormState, ActivityFormState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ActivityFormState, ActivityFormState>,
ActivityFormState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -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<void> build() {}
ScheduledActivityEntity? _findOverlap({
required List<ScheduledActivityEntity> 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<void> createActivity({
required String deviceId,
required String name,
required int weekDay,
required String period,
required List<ScheduledActivityEntity> 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<void> updateActivity({
required String deviceId,
required String activityId,
required int weekDay,
required String name,
required String period,
required List<ScheduledActivityEntity> 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<void> 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(),
);
});
}
}

View File

@@ -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<ScheduledActivitiesController, void> {
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<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
build();
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleValue(ref, null);
}
}

View File

@@ -0,0 +1,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<List<ScheduledActivityEntity>> 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);
});
}

View File

@@ -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<ScheduledActivityEntity>>,
List<ScheduledActivityEntity>,
FutureOr<List<ScheduledActivityEntity>>
>
with
$FutureModifier<List<ScheduledActivityEntity>>,
$FutureProvider<List<ScheduledActivityEntity>> {
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<List<ScheduledActivityEntity>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<ScheduledActivityEntity>> 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<List<ScheduledActivityEntity>>,
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';
}

View File

@@ -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(),

View File

@@ -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<ScheduledActivitiesViewState> {
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<void> _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<void> 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<void> 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<void> 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<void> _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<ScheduledActivityEntity> _sortActivities(
List<ScheduledActivityEntity> 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;
}
}

View File

@@ -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<ScheduledActivityEntity> activities,
@Default(true) bool isLoading,
@Default('') String errorMessage,
}) = _ScheduledActivitiesViewState;
}

View File

@@ -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>(T value) => value;
/// @nodoc
mixin _$ScheduledActivitiesViewState {
List<ScheduledActivityEntity> 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<ScheduledActivitiesViewState> get copyWith => _$ScheduledActivitiesViewStateCopyWithImpl<ScheduledActivitiesViewState>(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<ScheduledActivityEntity> 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<ScheduledActivityEntity>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( List<ScheduledActivityEntity> 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 extends Object?>(TResult Function( List<ScheduledActivityEntity> 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 extends Object?>(TResult? Function( List<ScheduledActivityEntity> 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<ScheduledActivityEntity> activities = const [], this.isLoading = true, this.errorMessage = ''}): _activities = activities;
final List<ScheduledActivityEntity> _activities;
@override@JsonKey() List<ScheduledActivityEntity> 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<ScheduledActivityEntity> 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<ScheduledActivityEntity>,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

View File

@@ -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<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
@@ -35,94 +37,82 @@ class ActivityFormSheet extends ConsumerStatefulWidget {
class _ActivityFormSheetState extends ConsumerState<ActivityFormSheet> {
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<void> _submit() async {
if (!_isFormValid) return;
Future<void> _submit({
required ActivityFormState formState,
required List<ScheduledActivityEntity> 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 <ScheduledActivityEntity>[]
: (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<ActivityFormSheet> {
),
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<ActivityFormSheet> {
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<ActivityFormSheet> {
),
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<ActivityFormSheet> {
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<ActivityFormSheet> {
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<TimeOfDay> onChanged;
const _InlineTimePicker({required this.initialTime, required this.onChanged});
const _InlineTimePicker({
required this.initialTime,
required this.onChanged,
});
@override
Widget build(BuildContext context) {

View File

@@ -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),
),
),
],
),
],
),
);
}
}

View File

@@ -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<ScheduledActivityEntity> 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<bool>(
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,

View File

@@ -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<AsyncError<void>>());
expect(state.error, isA<ScheduledActivityOverlapException>());
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<AsyncData<void>>(),
);
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<AsyncData<void>>(),
);
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<AsyncData<void>>(),
);
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<AsyncError<void>>(),
);
});
});
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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é",

View File

@@ -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à",

View File

@@ -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",

View File

@@ -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';