diff --git a/lib/core/constants/endpoint.dart b/lib/core/constants/endpoint.dart index 5332edce..04ec5cad 100644 --- a/lib/core/constants/endpoint.dart +++ b/lib/core/constants/endpoint.dart @@ -59,6 +59,13 @@ class Endpoint { static String get updateDefaultPreparation => _defaultPreparation; + static const _preparationTemplates = '/preparation-templates'; + + static String get preparationTemplates => _preparationTemplates; + + static String preparationTemplateById(String templateId) => + '$_preparationTemplates/$templateId'; + static const _updateSpareTime = '/users/me/spare-time'; static String get updateSpareTime => _updateSpareTime; diff --git a/lib/core/dio/api_error_message.dart b/lib/core/dio/api_error_message.dart index dea5cb56..ec55cfcd 100644 --- a/lib/core/dio/api_error_message.dart +++ b/lib/core/dio/api_error_message.dart @@ -20,6 +20,19 @@ class ApiErrorMessage { return message.trim(); } + final codeMessage = _messageFromCode(data['code']); + if (codeMessage != null) { + return codeMessage; + } + + final error = data['error']; + if (error is Map) { + final nestedCodeMessage = _messageFromCode(error['code']); + if (nestedCodeMessage != null) { + return nestedCodeMessage; + } + } + final errors = data['data']; if (errors is Map) { final errorList = errors['errors']; @@ -36,4 +49,23 @@ class ApiErrorMessage { return null; } + + static String? _messageFromCode(Object? code) { + if (code is! String) { + return null; + } + return switch (code) { + 'PREPARATION_TEMPLATE_NOT_FOUND' => 'Preparation template not found.', + 'PREPARATION_TEMPLATE_NAME_DUPLICATE' => + 'A preparation template with this name already exists.', + 'PREPARATION_TEMPLATE_LIMIT_EXCEEDED' => + 'You can create up to 20 active preparation templates.', + 'PREPARATION_TEMPLATE_DELETED' => + 'This preparation template has been deleted.', + 'PREPARATION_STEP_ID_CONFLICT' => + 'A preparation step ID is already used by another preparation.', + 'INVALID_INPUT' => 'Invalid input.', + _ => null, + }; + } } diff --git a/lib/data/data_sources/preparation_template_remote_data_source.dart b/lib/data/data_sources/preparation_template_remote_data_source.dart new file mode 100644 index 00000000..9ee71640 --- /dev/null +++ b/lib/data/data_sources/preparation_template_remote_data_source.dart @@ -0,0 +1,112 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/constants/endpoint.dart'; +import 'package:on_time_front/data/models/preparation_template_model.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; + +abstract interface class PreparationTemplateRemoteDataSource { + Future> getPreparationTemplates(); + + Future getPreparationTemplate(String templateId); + + Future createPreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }); + + Future updatePreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }); + + Future deletePreparationTemplate(String templateId); +} + +@Injectable(as: PreparationTemplateRemoteDataSource) +class PreparationTemplateRemoteDataSourceImpl + implements PreparationTemplateRemoteDataSource { + final Dio dio; + + PreparationTemplateRemoteDataSourceImpl(this.dio); + + @override + Future> getPreparationTemplates() async { + final result = await dio.get(Endpoint.preparationTemplates); + if (result.statusCode == 200) { + return (result.data['data'] as List) + .map( + (item) => PreparationTemplateModel.fromJson( + item as Map, + ).toEntity(), + ) + .toList(); + } + throw Exception('Error getting preparation templates'); + } + + @override + Future getPreparationTemplate( + String templateId, + ) async { + final result = await dio.get(Endpoint.preparationTemplateById(templateId)); + if (result.statusCode == 200) { + return PreparationTemplateModel.fromJson( + result.data['data'] as Map, + ).toEntity(); + } + throw Exception('Error getting preparation template'); + } + + @override + Future createPreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }) async { + final request = UpsertPreparationTemplateRequestModel.fromValues( + templateId: templateId, + templateName: templateName, + preparation: preparation, + ); + final result = await dio.post( + Endpoint.preparationTemplates, + data: request.toJson(), + ); + if (result.statusCode != 200) { + throw Exception('Error creating preparation template'); + } + } + + @override + Future updatePreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }) async { + final request = UpsertPreparationTemplateRequestModel.fromValues( + templateId: templateId, + templateName: templateName, + preparation: preparation, + ); + final result = await dio.put( + Endpoint.preparationTemplateById(templateId), + data: request.toJson(), + ); + if (result.statusCode != 200) { + throw Exception('Error updating preparation template'); + } + } + + @override + Future deletePreparationTemplate(String templateId) async { + final result = await dio.delete( + Endpoint.preparationTemplateById(templateId), + ); + if (result.statusCode != 200) { + throw Exception('Error deleting preparation template'); + } + } +} diff --git a/lib/data/data_sources/schedule_remote_data_source.dart b/lib/data/data_sources/schedule_remote_data_source.dart index b828009e..5ad788ae 100644 --- a/lib/data/data_sources/schedule_remote_data_source.dart +++ b/lib/data/data_sources/schedule_remote_data_source.dart @@ -12,11 +12,16 @@ abstract interface class ScheduleRemoteDataSource { Future createSchedule(ScheduleEntity schedule); Future> getSchedulesByDate( - DateTime startDate, DateTime? endDate); + DateTime startDate, + DateTime? endDate, + ); Future getScheduleById(String id); - Future updateSchedule(ScheduleEntity schedule); + Future updateSchedule( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }); Future deleteSchedule(ScheduleEntity schedule); @@ -33,8 +38,10 @@ class ScheduleRemoteDataSourceImpl implements ScheduleRemoteDataSource { try { CreateScheduleRequestModel createScheduleModel = CreateScheduleRequestModel.fromEntity(schedule); - final result = await dio.post(Endpoint.createSchedule, - data: createScheduleModel.toJson()); + final result = await dio.post( + Endpoint.createSchedule, + data: createScheduleModel.toJson(), + ); if (result.statusCode == 200) { return; } else { @@ -46,12 +53,20 @@ class ScheduleRemoteDataSourceImpl implements ScheduleRemoteDataSource { } @override - Future updateSchedule(ScheduleEntity schedule) async { + Future updateSchedule( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) async { try { UpdateScheduleRequestModel updateScheduleModel = - UpdateScheduleRequestModel.fromEntity(schedule); - final result = await dio.put(Endpoint.updateSchedule(schedule.id), - data: updateScheduleModel.toJson()); + UpdateScheduleRequestModel.fromEntity( + schedule, + includePreparationSource: includePreparationSource, + ); + final result = await dio.put( + Endpoint.updateSchedule(schedule.id), + data: updateScheduleModel.toJson(), + ); if (result.statusCode == 200) { return; } else { @@ -81,10 +96,7 @@ class ScheduleRemoteDataSourceImpl implements ScheduleRemoteDataSource { try { final result = await dio.put( Endpoint.finishSchedule(scheduleId), - data: { - 'scheduleId': scheduleId, - 'latenessTime': latenessTime, - }, + data: {'scheduleId': scheduleId, 'latenessTime': latenessTime}, ); if (result.statusCode == 200) { return; @@ -114,17 +126,22 @@ class ScheduleRemoteDataSourceImpl implements ScheduleRemoteDataSource { @override Future> getSchedulesByDate( - DateTime startDate, DateTime? endDate) async { + DateTime startDate, + DateTime? endDate, + ) async { try { - final result = - await dio.get(Endpoint.getSchedulesByDate, queryParameters: { - 'startDate': startDate.toIso8601String(), - 'endDate': endDate?.toIso8601String() ?? '', - }); + final result = await dio.get( + Endpoint.getSchedulesByDate, + queryParameters: { + 'startDate': startDate.toIso8601String(), + 'endDate': endDate?.toIso8601String() ?? '', + }, + ); if (result.statusCode == 200) { final List schedules = result.data["data"] .map( - (e) => GetScheduleResponseModel.fromJson(e).toEntity()) + (e) => GetScheduleResponseModel.fromJson(e).toEntity(), + ) .toList(); return schedules; } else { diff --git a/lib/data/models/alarm_window_schedule_model.dart b/lib/data/models/alarm_window_schedule_model.dart index 922d60dd..6e052dff 100644 --- a/lib/data/models/alarm_window_schedule_model.dart +++ b/lib/data/models/alarm_window_schedule_model.dart @@ -3,6 +3,7 @@ import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; class AlarmWindowScheduleModel { @@ -14,6 +15,13 @@ class AlarmWindowScheduleModel { final int scheduleSpareTime; final String doneStatus; final List preparations; + final DateTime? startedAt; + final DateTime? finishedAt; + final SchedulePreparationMode? preparationMode; + final String? preparationTemplateId; + final String? preparationTemplateName; + final bool preparationTemplateDeleted; + final bool preparationFrozen; const AlarmWindowScheduleModel({ required this.scheduleId, @@ -24,6 +32,13 @@ class AlarmWindowScheduleModel { required this.scheduleSpareTime, required this.doneStatus, required this.preparations, + this.startedAt, + this.finishedAt, + this.preparationMode, + this.preparationTemplateId, + this.preparationTemplateName, + this.preparationTemplateDeleted = false, + this.preparationFrozen = false, }); factory AlarmWindowScheduleModel.fromJson(Map json) { @@ -42,13 +57,32 @@ class AlarmWindowScheduleModel { scheduleSpareTime: (json['scheduleSpareTime'] as num?)?.toInt() ?? 0, doneStatus: json['doneStatus'] as String? ?? 'NOT_ENDED', preparations: preparationJson - .map((item) => AlarmWindowPreparationStepModel.fromJson( - item as Map)) + .map( + (item) => AlarmWindowPreparationStepModel.fromJson( + item as Map, + ), + ) .toList(), + startedAt: _optionalDateTime(json['startedAt']), + finishedAt: _optionalDateTime(json['finishedAt']), + preparationMode: _preparationModeFromJson(json['preparationMode']), + preparationTemplateId: json['preparationTemplateId'] as String?, + preparationTemplateName: json['preparationTemplateName'] as String?, + preparationTemplateDeleted: + json['preparationTemplateDeleted'] as bool? ?? false, + preparationFrozen: + json['preparationFrozen'] as bool? ?? json['startedAt'] != null, ); } ScheduleWithPreparationEntity toEntity() { + final hasOrderedShape = preparations.every( + (preparation) => preparation.orderIndex != null, + ); + final sortedPreparations = [...preparations]; + if (hasOrderedShape) { + sortedPreparations.sort((a, b) => a.orderIndex!.compareTo(b.orderIndex!)); + } return ScheduleWithPreparationEntity( id: scheduleId, place: place, @@ -56,14 +90,29 @@ class AlarmWindowScheduleModel { scheduleTime: scheduleTime, moveTime: Duration(minutes: moveTime), isChanged: false, - isStarted: false, + isStarted: preparationFrozen || startedAt != null, scheduleSpareTime: Duration(minutes: scheduleSpareTime), scheduleNote: '', doneStatus: _mapDoneStatus(doneStatus), + startedAt: startedAt, + finishedAt: finishedAt, + preparationMode: preparationMode, + preparationTemplateId: preparationTemplateId, + preparationTemplateName: preparationTemplateName, + preparationTemplateDeleted: preparationTemplateDeleted, + preparationFrozen: preparationFrozen, preparation: PreparationWithTimeEntity.fromPreparation( PreparationEntity( - preparationStepList: - preparations.map((step) => step.toEntity()).toList(), + preparationStepList: [ + for (var index = 0; index < sortedPreparations.length; index++) + hasOrderedShape + ? sortedPreparations[index].toEntity( + nextPreparationId: index + 1 < sortedPreparations.length + ? sortedPreparations[index + 1].id + : null, + ) + : sortedPreparations[index].toEntity(), + ], ), ), ); @@ -75,12 +124,14 @@ class AlarmWindowPreparationStepModel { final String preparationName; final int preparationTime; final String? nextPreparationId; + final int? orderIndex; const AlarmWindowPreparationStepModel({ required this.id, required this.preparationName, required this.preparationTime, this.nextPreparationId, + this.orderIndex, }); factory AlarmWindowPreparationStepModel.fromJson(Map json) { @@ -89,15 +140,16 @@ class AlarmWindowPreparationStepModel { preparationName: json['preparationName'] as String? ?? '', preparationTime: (json['preparationTime'] as num?)?.toInt() ?? 0, nextPreparationId: json['nextPreparationId'] as String?, + orderIndex: (json['orderIndex'] as num?)?.toInt(), ); } - PreparationStepEntity toEntity() { + PreparationStepEntity toEntity({String? nextPreparationId}) { return PreparationStepEntity( id: id, preparationName: preparationName, preparationTime: Duration(minutes: preparationTime), - nextPreparationId: nextPreparationId, + nextPreparationId: nextPreparationId ?? this.nextPreparationId, ); } } @@ -115,3 +167,22 @@ ScheduleDoneStatus _mapDoneStatus(String? serverValue) { return ScheduleDoneStatus.notEnded; } } + +DateTime? _optionalDateTime(Object? value) { + if (value is String && value.isNotEmpty) { + return DateTime.parse(value); + } + return null; +} + +SchedulePreparationMode? _preparationModeFromJson(Object? value) { + switch (value) { + case 'DEFAULT': + return SchedulePreparationMode.defaultPreparation; + case 'TEMPLATE': + return SchedulePreparationMode.template; + case 'CUSTOM': + return SchedulePreparationMode.custom; + } + return null; +} diff --git a/lib/data/models/create_schedule_request_model.dart b/lib/data/models/create_schedule_request_model.dart index eee3c915..f9bb7507 100644 --- a/lib/data/models/create_schedule_request_model.dart +++ b/lib/data/models/create_schedule_request_model.dart @@ -1,10 +1,13 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; part 'create_schedule_request_model.g.dart'; -@JsonSerializable() +@JsonSerializable(includeIfNull: false, explicitToJson: true) class CreateScheduleRequestModel { final String scheduleId; final String placeId; @@ -16,6 +19,8 @@ class CreateScheduleRequestModel { final bool isStarted; final int? scheduleSpareTime; final String scheduleNote; + final String? preparationTemplateId; + final List? customPreparations; const CreateScheduleRequestModel({ required this.scheduleId, @@ -28,6 +33,8 @@ class CreateScheduleRequestModel { required this.isStarted, required this.scheduleSpareTime, required this.scheduleNote, + this.preparationTemplateId, + this.customPreparations, }); factory CreateScheduleRequestModel.fromJson(Map json) => @@ -36,6 +43,16 @@ class CreateScheduleRequestModel { Map toJson() => _$CreateScheduleRequestModelToJson(this); static CreateScheduleRequestModel fromEntity(ScheduleEntity entity) { + final mode = _resolveCreateMode(entity); + final preparationTemplateId = mode == SchedulePreparationMode.template + ? _requireTemplateId(entity) + : null; + final customPreparations = mode == SchedulePreparationMode.custom + ? OrderedPreparationStepModel.fromPreparationEntity( + _requireCustomPreparations(entity), + ) + : null; + return CreateScheduleRequestModel( scheduleId: entity.id, placeId: entity.place.id, @@ -53,6 +70,45 @@ class CreateScheduleRequestModel { entity.scheduleNote, BackendConstraints.maxLongTextLength, ), + preparationTemplateId: preparationTemplateId, + customPreparations: customPreparations, + ); + } +} + +SchedulePreparationMode _resolveCreateMode(ScheduleEntity entity) { + if (entity.preparationMode != null) { + return entity.preparationMode!; + } + if (entity.preparationTemplateId != null) { + return SchedulePreparationMode.template; + } + if (entity.customPreparations != null) { + return SchedulePreparationMode.custom; + } + return SchedulePreparationMode.defaultPreparation; +} + +String _requireTemplateId(ScheduleEntity entity) { + final templateId = entity.preparationTemplateId; + if (templateId == null || templateId.isEmpty) { + throw ArgumentError('TEMPLATE schedules require preparationTemplateId'); + } + if (entity.customPreparations != null) { + throw ArgumentError('TEMPLATE schedules cannot include customPreparations'); + } + return templateId; +} + +PreparationEntity _requireCustomPreparations(ScheduleEntity entity) { + final preparation = entity.customPreparations; + if (preparation == null) { + throw ArgumentError('CUSTOM schedules require customPreparations'); + } + if (entity.preparationTemplateId != null) { + throw ArgumentError( + 'CUSTOM schedules cannot include preparationTemplateId', ); } + return preparation; } diff --git a/lib/data/models/get_schedule_response_model.dart b/lib/data/models/get_schedule_response_model.dart index 33647443..5604df2f 100644 --- a/lib/data/models/get_schedule_response_model.dart +++ b/lib/data/models/get_schedule_response_model.dart @@ -1,10 +1,12 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:on_time_front/data/models/get_place_response_model.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; part 'get_schedule_response_model.g.dart'; -@JsonSerializable() +@JsonSerializable(createFactory: false) class GetScheduleResponseModel { final String scheduleId; final GetPlaceResponseModel place; @@ -15,6 +17,13 @@ class GetScheduleResponseModel { final String scheduleNote; final int? latenessTime; final String? doneStatus; + final DateTime? startedAt; + final DateTime? finishedAt; + final SchedulePreparationMode? preparationMode; + final String? preparationTemplateId; + final String? preparationTemplateName; + final bool preparationTemplateDeleted; + final bool preparationFrozen; const GetScheduleResponseModel({ required this.scheduleId, @@ -26,6 +35,13 @@ class GetScheduleResponseModel { required this.scheduleNote, this.latenessTime = 0, this.doneStatus = 'NOT_ENDED', + this.startedAt, + this.finishedAt, + this.preparationMode, + this.preparationTemplateId, + this.preparationTemplateName, + this.preparationTemplateDeleted = false, + this.preparationFrozen = false, }); ScheduleEntity toEntity() { @@ -36,20 +52,79 @@ class GetScheduleResponseModel { scheduleTime: scheduleTime, moveTime: Duration(minutes: moveTime), isChanged: false, - isStarted: false, + isStarted: preparationFrozen || startedAt != null, scheduleSpareTime: Duration(minutes: scheduleSpareTime), scheduleNote: scheduleNote, latenessTime: latenessTime ?? -1, doneStatus: _mapDoneStatus(doneStatus), + startedAt: startedAt, + finishedAt: finishedAt, + preparationMode: preparationMode, + preparationTemplateId: preparationTemplateId, + preparationTemplateName: preparationTemplateName, + preparationTemplateDeleted: preparationTemplateDeleted, + preparationFrozen: preparationFrozen, ); } - factory GetScheduleResponseModel.fromJson(Map json) => - _$GetScheduleResponseModelFromJson(json); + factory GetScheduleResponseModel.fromJson(Map json) { + return GetScheduleResponseModel( + scheduleId: json['scheduleId'] as String, + place: _placeFromJson(json), + scheduleName: json['scheduleName'] as String? ?? '', + scheduleTime: DateTime.parse(json['scheduleTime'] as String), + moveTime: (json['moveTime'] as num?)?.toInt() ?? 0, + scheduleSpareTime: (json['scheduleSpareTime'] as num?)?.toInt() ?? 0, + scheduleNote: json['scheduleNote'] as String? ?? '', + latenessTime: (json['latenessTime'] as num?)?.toInt() ?? 0, + doneStatus: json['doneStatus'] as String? ?? 'NOT_ENDED', + startedAt: _optionalDateTime(json['startedAt']), + finishedAt: _optionalDateTime(json['finishedAt']), + preparationMode: _preparationModeFromJson(json['preparationMode']), + preparationTemplateId: json['preparationTemplateId'] as String?, + preparationTemplateName: json['preparationTemplateName'] as String?, + preparationTemplateDeleted: + json['preparationTemplateDeleted'] as bool? ?? false, + preparationFrozen: + json['preparationFrozen'] as bool? ?? json['startedAt'] != null, + ); + } Map toJson() => _$GetScheduleResponseModelToJson(this); } +GetPlaceResponseModel _placeFromJson(Map json) { + final placeJson = json['place']; + if (placeJson is Map) { + return GetPlaceResponseModel.fromJson(placeJson); + } + return GetPlaceResponseModel.fromEntity( + PlaceEntity( + id: json['placeId'] as String? ?? '', + placeName: json['placeName'] as String? ?? '', + ), + ); +} + +DateTime? _optionalDateTime(Object? value) { + if (value is String && value.isNotEmpty) { + return DateTime.parse(value); + } + return null; +} + +SchedulePreparationMode? _preparationModeFromJson(Object? value) { + switch (value) { + case 'DEFAULT': + return SchedulePreparationMode.defaultPreparation; + case 'TEMPLATE': + return SchedulePreparationMode.template; + case 'CUSTOM': + return SchedulePreparationMode.custom; + } + return null; +} + ScheduleDoneStatus _mapDoneStatus(String? serverValue) { switch (serverValue) { case 'LATE': diff --git a/lib/data/models/ordered_preparation_step_model.dart b/lib/data/models/ordered_preparation_step_model.dart new file mode 100644 index 00000000..a7458634 --- /dev/null +++ b/lib/data/models/ordered_preparation_step_model.dart @@ -0,0 +1,86 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; + +class OrderedPreparationStepModel { + @JsonKey(name: 'preparationId') + final String id; + final String preparationName; + final int preparationTime; + final int orderIndex; + + const OrderedPreparationStepModel({ + required this.id, + required this.preparationName, + required this.preparationTime, + required this.orderIndex, + }); + + factory OrderedPreparationStepModel.fromJson(Map json) { + return OrderedPreparationStepModel( + id: json['preparationId'] as String, + preparationName: json['preparationName'] as String, + preparationTime: (json['preparationTime'] as num).toInt(), + orderIndex: (json['orderIndex'] as num).toInt(), + ); + } + + Map toJson() => { + 'preparationId': id, + 'preparationName': preparationName, + 'preparationTime': preparationTime, + 'orderIndex': orderIndex, + }; + + static List fromPreparationEntity( + PreparationEntity preparation, + ) { + final orderedSteps = preparation.ordered.preparationStepList; + return [ + for (var index = 0; index < orderedSteps.length; index++) + OrderedPreparationStepModel.fromEntity( + orderedSteps[index], + orderIndex: index, + ), + ]; + } + + static OrderedPreparationStepModel fromEntity( + PreparationStepEntity entity, { + required int orderIndex, + }) { + return OrderedPreparationStepModel( + id: entity.id, + preparationName: entity.preparationName, + preparationTime: entity.preparationTime.inMinutes, + orderIndex: orderIndex, + ); + } + + PreparationStepEntity toEntity({String? nextPreparationId}) { + return PreparationStepEntity( + id: id, + preparationName: preparationName, + preparationTime: Duration(minutes: preparationTime), + nextPreparationId: nextPreparationId, + ); + } +} + +extension OrderedPreparationStepModelListExtension + on List { + PreparationEntity toPreparationEntity() { + final sorted = [...this] + ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex)); + return PreparationEntity( + preparationStepList: [ + for (var index = 0; index < sorted.length; index++) + sorted[index].toEntity( + nextPreparationId: index + 1 < sorted.length + ? sorted[index + 1].id + : null, + ), + ], + ); + } +} diff --git a/lib/data/models/preparation_template_model.dart b/lib/data/models/preparation_template_model.dart new file mode 100644 index 00000000..7af4d77b --- /dev/null +++ b/lib/data/models/preparation_template_model.dart @@ -0,0 +1,125 @@ +import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; + +class PreparationTemplateModel { + final String templateId; + final String templateName; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final List preparations; + + const PreparationTemplateModel({ + required this.templateId, + required this.templateName, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.preparations, + }); + + factory PreparationTemplateModel.fromJson(Map json) { + return PreparationTemplateModel( + templateId: json['templateId'] as String, + templateName: json['templateName'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + deletedAt: _optionalDateTime(json['deletedAt']), + preparations: (json['preparations'] as List? ?? const []) + .map( + (item) => OrderedPreparationStepModel.fromJson( + item as Map, + ), + ) + .toList(), + ); + } + + Map toJson() => { + 'templateId': templateId, + 'templateName': templateName, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'deletedAt': deletedAt?.toIso8601String(), + 'preparations': preparations.map((step) => step.toJson()).toList(), + }; + + PreparationTemplateEntity toEntity() { + return PreparationTemplateEntity( + id: templateId, + name: templateName, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + preparation: preparations.toPreparationEntity(), + ); + } +} + +class UpsertPreparationTemplateRequestModel { + final String templateId; + final String templateName; + final List preparations; + + const UpsertPreparationTemplateRequestModel({ + required this.templateId, + required this.templateName, + required this.preparations, + }); + + factory UpsertPreparationTemplateRequestModel.fromJson( + Map json, + ) { + return UpsertPreparationTemplateRequestModel( + templateId: json['templateId'] as String, + templateName: json['templateName'] as String, + preparations: (json['preparations'] as List) + .map( + (item) => OrderedPreparationStepModel.fromJson( + item as Map, + ), + ) + .toList(), + ); + } + + Map toJson() => { + 'templateId': templateId, + 'templateName': templateName, + 'preparations': preparations.map((step) => step.toJson()).toList(), + }; + + static UpsertPreparationTemplateRequestModel fromEntity( + PreparationTemplateEntity entity, + ) { + return UpsertPreparationTemplateRequestModel( + templateId: entity.id, + templateName: entity.name, + preparations: OrderedPreparationStepModel.fromPreparationEntity( + entity.preparation, + ), + ); + } + + static UpsertPreparationTemplateRequestModel fromValues({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }) { + return UpsertPreparationTemplateRequestModel( + templateId: templateId, + templateName: templateName, + preparations: OrderedPreparationStepModel.fromPreparationEntity( + preparation, + ), + ); + } +} + +DateTime? _optionalDateTime(Object? value) { + if (value is String && value.isNotEmpty) { + return DateTime.parse(value); + } + return null; +} diff --git a/lib/data/models/update_schedule_request_model.dart b/lib/data/models/update_schedule_request_model.dart index 2190ac9b..5ab59ff4 100644 --- a/lib/data/models/update_schedule_request_model.dart +++ b/lib/data/models/update_schedule_request_model.dart @@ -1,10 +1,12 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; part 'update_schedule_request_model.g.dart'; -@JsonSerializable() +@JsonSerializable(includeIfNull: false, explicitToJson: true) class UpdateScheduleRequestModel { final String scheduleId; final String placeId; @@ -14,6 +16,9 @@ class UpdateScheduleRequestModel { final int moveTime; final int? scheduleSpareTime; final String scheduleNote; + final SchedulePreparationMode? preparationMode; + final String? preparationTemplateId; + final List? customPreparations; const UpdateScheduleRequestModel({ required this.scheduleId, @@ -24,6 +29,9 @@ class UpdateScheduleRequestModel { required this.moveTime, this.scheduleSpareTime, required this.scheduleNote, + this.preparationMode, + this.preparationTemplateId, + this.customPreparations, }); factory UpdateScheduleRequestModel.fromJson(Map json) => @@ -31,7 +39,19 @@ class UpdateScheduleRequestModel { Map toJson() => _$UpdateScheduleRequestModelToJson(this); - static UpdateScheduleRequestModel fromEntity(ScheduleEntity entity) { + static UpdateScheduleRequestModel fromEntity( + ScheduleEntity entity, { + bool includePreparationSource = false, + }) { + final preparationMode = includePreparationSource + ? entity.preparationMode + : null; + final preparationTemplateId = _templateIdForMode(entity, preparationMode); + final customPreparations = _customPreparationsForMode( + entity, + preparationMode, + ); + return UpdateScheduleRequestModel( scheduleId: entity.id, placeId: entity.place.id, @@ -47,6 +67,45 @@ class UpdateScheduleRequestModel { entity.scheduleNote, BackendConstraints.maxLongTextLength, ), + preparationMode: preparationMode, + preparationTemplateId: preparationTemplateId, + customPreparations: customPreparations, + ); + } +} + +String? _templateIdForMode( + ScheduleEntity entity, + SchedulePreparationMode? preparationMode, +) { + if (preparationMode != SchedulePreparationMode.template) { + return null; + } + final templateId = entity.preparationTemplateId; + if (templateId == null || templateId.isEmpty) { + throw ArgumentError('TEMPLATE schedules require preparationTemplateId'); + } + if (entity.customPreparations != null) { + throw ArgumentError('TEMPLATE schedules cannot include customPreparations'); + } + return templateId; +} + +List? _customPreparationsForMode( + ScheduleEntity entity, + SchedulePreparationMode? preparationMode, +) { + if (preparationMode != SchedulePreparationMode.custom) { + return null; + } + final preparation = entity.customPreparations; + if (preparation == null) { + throw ArgumentError('CUSTOM schedules require customPreparations'); + } + if (entity.preparationTemplateId != null) { + throw ArgumentError( + 'CUSTOM schedules cannot include preparationTemplateId', ); } + return OrderedPreparationStepModel.fromPreparationEntity(preparation); } diff --git a/lib/data/repositories/preparation_repository_impl.dart b/lib/data/repositories/preparation_repository_impl.dart index 6f2b828c..834f1dc7 100644 --- a/lib/data/repositories/preparation_repository_impl.dart +++ b/lib/data/repositories/preparation_repository_impl.dart @@ -17,8 +17,8 @@ class PreparationRepositoryImpl implements PreparationRepository { late final _preparationStreamController = BehaviorSubject>.seeded( - const {}, - ); + const {}, + ); PreparationRepositoryImpl({ required this.preparationRemoteDataSource, @@ -30,16 +30,19 @@ class PreparationRepositoryImpl implements PreparationRepository { _preparationStreamController.asBroadcastStream(); @override - Future createDefaultPreparation( - {required PreparationEntity preparationEntity, - required Duration spareTime, - required String note}) async { + Future createDefaultPreparation({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }) async { try { await preparationRemoteDataSource.createDefaultPreparation( - CreateDefaultPreparationRequestModel.fromEntity( - preparationEntity: preparationEntity, - spareTime: spareTime, - note: note)); + CreateDefaultPreparationRequestModel.fromEntity( + preparationEntity: preparationEntity, + spareTime: spareTime, + note: note, + ), + ); } catch (e) { rethrow; } @@ -47,13 +50,18 @@ class PreparationRepositoryImpl implements PreparationRepository { @override Future createCustomPreparation( - PreparationEntity preparationEntity, String scheduleId) async { + PreparationEntity preparationEntity, + String scheduleId, + ) async { try { await preparationRemoteDataSource.createCustomPreparation( - preparationEntity, scheduleId); + preparationEntity, + scheduleId, + ); _preparationStreamController.add( - Map.from(_preparationStreamController.value) - ..[scheduleId] = preparationEntity); + Map.from(_preparationStreamController.value) + ..[scheduleId] = preparationEntity, + ); } catch (e) { rethrow; } @@ -65,8 +73,9 @@ class PreparationRepositoryImpl implements PreparationRepository { final remotePreparation = await preparationRemoteDataSource .getPreparationByScheduleId(scheduleId); _preparationStreamController.add( - Map.from(_preparationStreamController.value) - ..[scheduleId] = remotePreparation); + Map.from(_preparationStreamController.value) + ..[scheduleId] = remotePreparation, + ); } catch (e) { rethrow; } @@ -75,8 +84,8 @@ class PreparationRepositoryImpl implements PreparationRepository { @override Future getDefualtPreparation() async { try { - final remotePreparation = - await preparationRemoteDataSource.getDefualtPreparation(); + final remotePreparation = await preparationRemoteDataSource + .getDefualtPreparation(); return remotePreparation; } catch (e) { rethrow; @@ -85,10 +94,17 @@ class PreparationRepositoryImpl implements PreparationRepository { @override Future updateDefaultPreparation( - PreparationEntity preparationEntity) async { + PreparationEntity preparationEntity, + ) async { try { - await preparationRemoteDataSource - .updateDefaultPreparation(preparationEntity); + await preparationRemoteDataSource.updateDefaultPreparation( + preparationEntity, + ); + final persistedPreparation = await preparationRemoteDataSource + .getDefualtPreparation(); + if (!_samePreparation(preparationEntity, persistedPreparation)) { + throw StateError('Default preparation update was not persisted.'); + } // await preparationLocalDataSource.updatePreparation(preparationEntity); } catch (e) { rethrow; @@ -97,13 +113,18 @@ class PreparationRepositoryImpl implements PreparationRepository { @override Future updatePreparationByScheduleId( - PreparationEntity preparationEntity, String scheduleId) async { + PreparationEntity preparationEntity, + String scheduleId, + ) async { try { await preparationRemoteDataSource.updatePreparationByScheduleId( - preparationEntity, scheduleId); + preparationEntity, + scheduleId, + ); _preparationStreamController.add( - Map.from(_preparationStreamController.value) - ..[scheduleId] = preparationEntity); + Map.from(_preparationStreamController.value) + ..[scheduleId] = preparationEntity, + ); } catch (e) { rethrow; } @@ -117,4 +138,23 @@ class PreparationRepositoryImpl implements PreparationRepository { rethrow; } } + + bool _samePreparation(PreparationEntity expected, PreparationEntity actual) { + final expectedSteps = expected.ordered.preparationStepList; + final actualSteps = actual.ordered.preparationStepList; + if (expectedSteps.length != actualSteps.length) { + return false; + } + for (var index = 0; index < expectedSteps.length; index++) { + final expectedStep = expectedSteps[index]; + final actualStep = actualSteps[index]; + if (expectedStep.id != actualStep.id || + expectedStep.preparationName.trim() != + actualStep.preparationName.trim() || + expectedStep.preparationTime != actualStep.preparationTime) { + return false; + } + } + return true; + } } diff --git a/lib/data/repositories/preparation_template_repository_impl.dart b/lib/data/repositories/preparation_template_repository_impl.dart new file mode 100644 index 00000000..329cb6c3 --- /dev/null +++ b/lib/data/repositories/preparation_template_repository_impl.dart @@ -0,0 +1,54 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/data/data_sources/preparation_template_remote_data_source.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_template_repository.dart'; + +@Singleton(as: PreparationTemplateRepository) +class PreparationTemplateRepositoryImpl + implements PreparationTemplateRepository { + final PreparationTemplateRemoteDataSource remoteDataSource; + + PreparationTemplateRepositoryImpl({required this.remoteDataSource}); + + @override + Future> getPreparationTemplates() { + return remoteDataSource.getPreparationTemplates(); + } + + @override + Future getPreparationTemplate(String templateId) { + return remoteDataSource.getPreparationTemplate(templateId); + } + + @override + Future createPreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }) { + return remoteDataSource.createPreparationTemplate( + templateId: templateId, + templateName: templateName, + preparation: preparation, + ); + } + + @override + Future updatePreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }) { + return remoteDataSource.updatePreparationTemplate( + templateId: templateId, + templateName: templateName, + preparation: preparation, + ); + } + + @override + Future deletePreparationTemplate(String templateId) { + return remoteDataSource.deletePreparationTemplate(templateId); + } +} diff --git a/lib/data/repositories/schedule_repository_impl.dart b/lib/data/repositories/schedule_repository_impl.dart index b72c7ef8..47a6327b 100644 --- a/lib/data/repositories/schedule_repository_impl.dart +++ b/lib/data/repositories/schedule_repository_impl.dart @@ -94,9 +94,15 @@ class ScheduleRepositoryImpl implements ScheduleRepository { } @override - Future updateSchedule(ScheduleEntity schedule) async { + Future updateSchedule( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) async { try { - await scheduleRemoteDataSource.updateSchedule(schedule); + await scheduleRemoteDataSource.updateSchedule( + schedule, + includePreparationSource: includePreparationSource, + ); await _clearTimedPreparationSafe(schedule.id); final refreshedSchedule = await scheduleRemoteDataSource.getScheduleById( schedule.id, @@ -156,12 +162,11 @@ class ScheduleRepositoryImpl implements ScheduleRepository { required Iterable schedules, }) { final nextSchedules = - Set.from(_scheduleStreamController.value) - ..removeWhere( - (existing) => - !existing.scheduleTime.isBefore(startDate) && - (endDate == null || existing.scheduleTime.isBefore(endDate)), - ); + Set.from(_scheduleStreamController.value)..removeWhere( + (existing) => + !existing.scheduleTime.isBefore(startDate) && + (endDate == null || existing.scheduleTime.isBefore(endDate)), + ); for (final schedule in schedules) { nextSchedules.add(schedule); } diff --git a/lib/domain/entities/preparation_template_entity.dart b/lib/domain/entities/preparation_template_entity.dart new file mode 100644 index 00000000..15cb08bd --- /dev/null +++ b/lib/domain/entities/preparation_template_entity.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; + +class PreparationTemplateEntity extends Equatable { + final String id; + final String name; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final PreparationEntity preparation; + + const PreparationTemplateEntity({ + required this.id, + required this.name, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.preparation, + }); + + bool get isDeleted => deletedAt != null; + + @override + List get props => [ + id, + name, + createdAt, + updatedAt, + deletedAt, + preparation, + ]; +} diff --git a/lib/domain/entities/schedule_entity.dart b/lib/domain/entities/schedule_entity.dart index b123eb21..644d24c4 100644 --- a/lib/domain/entities/schedule_entity.dart +++ b/lib/domain/entities/schedule_entity.dart @@ -3,6 +3,8 @@ import 'package:on_time_front/data/tables/schedule_with_place_model.dart'; import '/core/database/database.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; class ScheduleEntity extends Equatable { final String id; @@ -16,6 +18,14 @@ class ScheduleEntity extends Equatable { final String scheduleNote; final int latenessTime; final ScheduleDoneStatus doneStatus; + final DateTime? startedAt; + final DateTime? finishedAt; + final SchedulePreparationMode? preparationMode; + final String? preparationTemplateId; + final String? preparationTemplateName; + final bool preparationTemplateDeleted; + final bool preparationFrozen; + final PreparationEntity? customPreparations; const ScheduleEntity({ required this.id, @@ -29,10 +39,19 @@ class ScheduleEntity extends Equatable { required this.scheduleNote, this.latenessTime = 0, this.doneStatus = ScheduleDoneStatus.notEnded, + this.startedAt, + this.finishedAt, + this.preparationMode, + this.preparationTemplateId, + this.preparationTemplateName, + this.preparationTemplateDeleted = false, + this.preparationFrozen = false, + this.customPreparations, }); static ScheduleEntity fromScheduleWithPlaceModel( - ScheduleWithPlace scheduleWithPlace) { + ScheduleWithPlace scheduleWithPlace, + ) { final schedule = scheduleWithPlace.schedule; final place = scheduleWithPlace.place; return ScheduleEntity( @@ -47,6 +66,7 @@ class ScheduleEntity extends Equatable { scheduleNote: schedule.scheduleNote ?? '', latenessTime: schedule.latenessTime, doneStatus: ScheduleDoneStatus.notEnded, + preparationFrozen: schedule.isStarted, ); } @@ -74,6 +94,14 @@ class ScheduleEntity extends Equatable { ScheduleEntity copyWith({ ScheduleDoneStatus? doneStatus, + DateTime? startedAt, + DateTime? finishedAt, + SchedulePreparationMode? preparationMode, + String? preparationTemplateId, + String? preparationTemplateName, + bool? preparationTemplateDeleted, + bool? preparationFrozen, + PreparationEntity? customPreparations, }) { return ScheduleEntity( id: id, @@ -87,28 +115,47 @@ class ScheduleEntity extends Equatable { scheduleNote: scheduleNote, latenessTime: latenessTime, doneStatus: doneStatus ?? this.doneStatus, + startedAt: startedAt ?? this.startedAt, + finishedAt: finishedAt ?? this.finishedAt, + preparationMode: preparationMode ?? this.preparationMode, + preparationTemplateId: + preparationTemplateId ?? this.preparationTemplateId, + preparationTemplateName: + preparationTemplateName ?? this.preparationTemplateName, + preparationTemplateDeleted: + preparationTemplateDeleted ?? this.preparationTemplateDeleted, + preparationFrozen: preparationFrozen ?? this.preparationFrozen, + customPreparations: customPreparations ?? this.customPreparations, ); } @override String toString() { - return 'ScheduleEntity(id: $id, place: $place, scheduleName: $scheduleName, scheduleTime: $scheduleTime, moveTime: $moveTime, isChanged: $isChanged, isStarted: $isStarted, scheduleSpareTime: $scheduleSpareTime, scheduleNote: $scheduleNote, latenessTime: $latenessTime)'; + return 'ScheduleEntity(id: $id, place: $place, scheduleName: $scheduleName, scheduleTime: $scheduleTime, moveTime: $moveTime, isChanged: $isChanged, isStarted: $isStarted, scheduleSpareTime: $scheduleSpareTime, scheduleNote: $scheduleNote, latenessTime: $latenessTime, preparationMode: $preparationMode, preparationFrozen: $preparationFrozen)'; } @override List get props => [ - id, - place, - scheduleName, - scheduleTime, - moveTime, - isChanged, - isStarted, - scheduleSpareTime, - scheduleNote, - latenessTime, - doneStatus, - ]; + id, + place, + scheduleName, + scheduleTime, + moveTime, + isChanged, + isStarted, + scheduleSpareTime, + scheduleNote, + latenessTime, + doneStatus, + startedAt, + finishedAt, + preparationMode, + preparationTemplateId, + preparationTemplateName, + preparationTemplateDeleted, + preparationFrozen, + customPreparations, + ]; } enum ScheduleDoneStatus { diff --git a/lib/domain/entities/schedule_preparation_mode.dart b/lib/domain/entities/schedule_preparation_mode.dart new file mode 100644 index 00000000..c1785b99 --- /dev/null +++ b/lib/domain/entities/schedule_preparation_mode.dart @@ -0,0 +1,10 @@ +import 'package:json_annotation/json_annotation.dart'; + +enum SchedulePreparationMode { + @JsonValue('DEFAULT') + defaultPreparation, + @JsonValue('TEMPLATE') + template, + @JsonValue('CUSTOM') + custom, +} diff --git a/lib/domain/entities/schedule_with_preparation_entity.dart b/lib/domain/entities/schedule_with_preparation_entity.dart index 284d9fc4..20a2fdb4 100644 --- a/lib/domain/entities/schedule_with_preparation_entity.dart +++ b/lib/domain/entities/schedule_with_preparation_entity.dart @@ -16,6 +16,14 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { required super.scheduleNote, super.latenessTime, super.doneStatus, + super.startedAt, + super.finishedAt, + super.preparationMode, + super.preparationTemplateId, + super.preparationTemplateName, + super.preparationTemplateDeleted, + super.preparationFrozen, + super.customPreparations, required this.preparation, }); @@ -77,7 +85,9 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { } static ScheduleWithPreparationEntity fromScheduleAndPreparationEntity( - ScheduleEntity schedule, PreparationWithTimeEntity preparation) { + ScheduleEntity schedule, + PreparationWithTimeEntity preparation, + ) { return ScheduleWithPreparationEntity( id: schedule.id, place: schedule.place, @@ -90,21 +100,37 @@ class ScheduleWithPreparationEntity extends ScheduleEntity { scheduleNote: schedule.scheduleNote, latenessTime: schedule.latenessTime, doneStatus: schedule.doneStatus, + startedAt: schedule.startedAt, + finishedAt: schedule.finishedAt, + preparationMode: schedule.preparationMode, + preparationTemplateId: schedule.preparationTemplateId, + preparationTemplateName: schedule.preparationTemplateName, + preparationTemplateDeleted: schedule.preparationTemplateDeleted, + preparationFrozen: schedule.preparationFrozen, + customPreparations: schedule.customPreparations, preparation: preparation, ); } @override List get props => [ - id, - place, - scheduleName, - scheduleTime, - moveTime, - isChanged, - isStarted, - scheduleSpareTime, - scheduleNote, - preparation - ]; + id, + place, + scheduleName, + scheduleTime, + moveTime, + isChanged, + isStarted, + scheduleSpareTime, + scheduleNote, + doneStatus, + startedAt, + finishedAt, + preparationMode, + preparationTemplateId, + preparationTemplateName, + preparationTemplateDeleted, + preparationFrozen, + preparation, + ]; } diff --git a/lib/domain/repositories/preparation_template_repository.dart b/lib/domain/repositories/preparation_template_repository.dart new file mode 100644 index 00000000..914af213 --- /dev/null +++ b/lib/domain/repositories/preparation_template_repository.dart @@ -0,0 +1,22 @@ +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; + +abstract interface class PreparationTemplateRepository { + Future> getPreparationTemplates(); + + Future getPreparationTemplate(String templateId); + + Future createPreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }); + + Future updatePreparationTemplate({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }); + + Future deletePreparationTemplate(String templateId); +} diff --git a/lib/domain/repositories/schedule_repository.dart b/lib/domain/repositories/schedule_repository.dart index 65dbf351..e38f95b0 100644 --- a/lib/domain/repositories/schedule_repository.dart +++ b/lib/domain/repositories/schedule_repository.dart @@ -11,7 +11,9 @@ abstract interface class ScheduleRepository { /// if [endDate] is null, it will get all schedules after [startDate] /// This is for getting schedules by date Future> getSchedulesByDate( - DateTime startDate, DateTime? endDate); + DateTime startDate, + DateTime? endDate, + ); /// Get a schedule by [id] /// This is for getting a schedule by id @@ -19,7 +21,10 @@ abstract interface class ScheduleRepository { /// Update a schedule /// This is for updating a schedule - Future updateSchedule(ScheduleEntity schedule); + Future updateSchedule( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }); /// Delete a schedule /// This is for deleting a schedule diff --git a/lib/domain/use-cases/create_preparation_template_use_case.dart b/lib/domain/use-cases/create_preparation_template_use_case.dart new file mode 100644 index 00000000..ef9a5692 --- /dev/null +++ b/lib/domain/use-cases/create_preparation_template_use_case.dart @@ -0,0 +1,22 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_template_repository.dart'; + +@Injectable() +class CreatePreparationTemplateUseCase { + final PreparationTemplateRepository _repository; + + CreatePreparationTemplateUseCase(this._repository); + + Future call({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }) { + return _repository.createPreparationTemplate( + templateId: templateId, + templateName: templateName, + preparation: preparation, + ); + } +} diff --git a/lib/domain/use-cases/delete_preparation_template_use_case.dart b/lib/domain/use-cases/delete_preparation_template_use_case.dart new file mode 100644 index 00000000..7e9bbe90 --- /dev/null +++ b/lib/domain/use-cases/delete_preparation_template_use_case.dart @@ -0,0 +1,13 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/repositories/preparation_template_repository.dart'; + +@Injectable() +class DeletePreparationTemplateUseCase { + final PreparationTemplateRepository _repository; + + DeletePreparationTemplateUseCase(this._repository); + + Future call(String templateId) { + return _repository.deletePreparationTemplate(templateId); + } +} diff --git a/lib/domain/use-cases/get_preparation_template_use_case.dart b/lib/domain/use-cases/get_preparation_template_use_case.dart new file mode 100644 index 00000000..68651dad --- /dev/null +++ b/lib/domain/use-cases/get_preparation_template_use_case.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_template_repository.dart'; + +@Injectable() +class GetPreparationTemplateUseCase { + final PreparationTemplateRepository _repository; + + GetPreparationTemplateUseCase(this._repository); + + Future call(String templateId) { + return _repository.getPreparationTemplate(templateId); + } +} diff --git a/lib/domain/use-cases/get_preparation_templates_use_case.dart b/lib/domain/use-cases/get_preparation_templates_use_case.dart new file mode 100644 index 00000000..9bef7ead --- /dev/null +++ b/lib/domain/use-cases/get_preparation_templates_use_case.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/preparation_template_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_template_repository.dart'; + +@Injectable() +class GetPreparationTemplatesUseCase { + final PreparationTemplateRepository _repository; + + GetPreparationTemplatesUseCase(this._repository); + + Future> call() { + return _repository.getPreparationTemplates(); + } +} diff --git a/lib/domain/use-cases/update_preparation_template_use_case.dart b/lib/domain/use-cases/update_preparation_template_use_case.dart new file mode 100644 index 00000000..106e94c2 --- /dev/null +++ b/lib/domain/use-cases/update_preparation_template_use_case.dart @@ -0,0 +1,22 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_template_repository.dart'; + +@Injectable() +class UpdatePreparationTemplateUseCase { + final PreparationTemplateRepository _repository; + + UpdatePreparationTemplateUseCase(this._repository); + + Future call({ + required String templateId, + required String templateName, + required PreparationEntity preparation, + }) { + return _repository.updatePreparationTemplate( + templateId: templateId, + templateName: templateName, + preparation: preparation, + ); + } +} diff --git a/lib/domain/use-cases/update_schedule_use_case.dart b/lib/domain/use-cases/update_schedule_use_case.dart index b12d3eb0..6492116b 100644 --- a/lib/domain/use-cases/update_schedule_use_case.dart +++ b/lib/domain/use-cases/update_schedule_use_case.dart @@ -10,13 +10,16 @@ class UpdateScheduleUseCase { final ScheduleRepository _scheduleRepository; final ReconcileAlarmsUseCase _reconcileAlarmsUseCase; - UpdateScheduleUseCase( - this._scheduleRepository, - this._reconcileAlarmsUseCase, - ); + UpdateScheduleUseCase(this._scheduleRepository, this._reconcileAlarmsUseCase); - Future call(ScheduleEntity schedule) async { - await _scheduleRepository.updateSchedule(schedule); + Future call( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) async { + await _scheduleRepository.updateSchedule( + schedule, + includePreparationSource: includePreparationSource, + ); unawaited(_reconcileAlarmsUseCase()); } } diff --git a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart index 90a93cea..579588e6 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/dio/api_error_message.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/update_default_preparation_use_case.dart'; @@ -11,9 +12,12 @@ part 'default_preparation_spare_time_form_event.dart'; part 'default_preparation_spare_time_form_state.dart'; @injectable -class DefaultPreparationSpareTimeFormBloc extends Bloc< - DefaultPreparationSpareTimeFormEvent, - DefaultPreparationSpareTimeFormState> { +class DefaultPreparationSpareTimeFormBloc + extends + Bloc< + DefaultPreparationSpareTimeFormEvent, + DefaultPreparationSpareTimeFormState + > { DefaultPreparationSpareTimeFormBloc( this._getDefaultPreparationUseCase, this._updateDefaultPreparationUseCase, @@ -33,68 +37,91 @@ class DefaultPreparationSpareTimeFormBloc extends Bloc< final Duration lowerBound = Duration(minutes: 10); final Duration stepSize = Duration(minutes: 5); - Future _onFormEditRequested(FormEditRequested event, - Emitter emit) async { - emit(state.copyWith( - status: DefaultPreparationSpareTimeStatus.loading, - )); + Future _onFormEditRequested( + FormEditRequested event, + Emitter emit, + ) async { + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.loading, + errorMessage: null, + ), + ); final preparation = await _getDefaultPreparationUseCase(); - emit(state.copyWith( - status: DefaultPreparationSpareTimeStatus.success, - preparation: preparation, - spareTime: event.spareTime, - )); + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.success, + preparation: preparation, + spareTime: event.spareTime, + errorMessage: null, + ), + ); } - void _onSpareTimeIncreased(SpareTimeIncreased event, - Emitter emit) { + void _onSpareTimeIncreased( + SpareTimeIncreased event, + Emitter emit, + ) { final currentSpareTime = state.spareTime ?? Duration.zero; final newSpareTime = currentSpareTime + stepSize; - emit(state.copyWith( - spareTime: newSpareTime, - )); + emit(state.copyWith(spareTime: newSpareTime)); } - void _onSpareTimeDecreased(SpareTimeDecreased event, - Emitter emit) { + void _onSpareTimeDecreased( + SpareTimeDecreased event, + Emitter emit, + ) { final currentSpareTime = state.spareTime ?? Duration.zero; final newSpareTime = currentSpareTime - stepSize; if (newSpareTime >= lowerBound) { - emit(state.copyWith( - spareTime: newSpareTime, - )); + emit(state.copyWith(spareTime: newSpareTime)); } } - Future _onFormSubmitted(FormSubmitted event, - Emitter emit) async { + Future _onFormSubmitted( + FormSubmitted event, + Emitter emit, + ) async { if (state.spareTime == null) { - emit(state.copyWith( - status: DefaultPreparationSpareTimeStatus.error, - )); + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.error, + errorMessage: null, + ), + ); return; } - emit(state.copyWith( - status: DefaultPreparationSpareTimeStatus.loading, - )); + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.submitting, + errorMessage: null, + ), + ); try { await _updateDefaultPreparationUseCase(event.preparation); await _updateSpareTimeUseCase(state.spareTime!); await _loadUserUseCase(); - emit(state.copyWith( - status: DefaultPreparationSpareTimeStatus.success, - )); + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.submitted, + preparation: event.preparation, + errorMessage: null, + ), + ); } catch (e) { - emit(state.copyWith( - status: DefaultPreparationSpareTimeStatus.error, - )); + emit( + state.copyWith( + status: DefaultPreparationSpareTimeStatus.error, + errorMessage: ApiErrorMessage.fromException(e) ?? e.toString(), + ), + ); } } } diff --git a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart index 97adef4f..3337cf39 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_state.dart @@ -4,6 +4,8 @@ enum DefaultPreparationSpareTimeStatus { initial, loading, success, + submitting, + submitted, error, } @@ -12,23 +14,27 @@ class DefaultPreparationSpareTimeFormState extends Equatable { this.status = DefaultPreparationSpareTimeStatus.initial, this.spareTime, this.preparation, + this.errorMessage, }); final DefaultPreparationSpareTimeStatus status; final Duration? spareTime; final PreparationEntity? preparation; + final String? errorMessage; @override - List get props => [status, spareTime, preparation]; + List get props => [status, spareTime, preparation, errorMessage]; DefaultPreparationSpareTimeFormState copyWith({ DefaultPreparationSpareTimeStatus? status, Duration? spareTime, PreparationEntity? preparation, + String? errorMessage, }) { return DefaultPreparationSpareTimeFormState( status: status ?? this.status, spareTime: spareTime ?? this.spareTime, preparation: preparation ?? this.preparation, + errorMessage: errorMessage, ); } } diff --git a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart index 2f51c47c..ae1fcd8a 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart @@ -39,19 +39,45 @@ class _PreparationSpareTimeEditView extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocListener< - DefaultPreparationSpareTimeFormBloc, - DefaultPreparationSpareTimeFormState - >( - listenWhen: (previous, current) => - current.status == DefaultPreparationSpareTimeStatus.success && - current.preparation != null && - previous.preparation != current.preparation, - listener: (context, state) { - context.read().add( - PreparationFormEditRequested(preparationEntity: state.preparation!), - ); - }, + return MultiBlocListener( + listeners: [ + BlocListener< + DefaultPreparationSpareTimeFormBloc, + DefaultPreparationSpareTimeFormState + >( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (state.status == DefaultPreparationSpareTimeStatus.submitted) { + Navigator.of(context).pop(); + } else if (state.status == + DefaultPreparationSpareTimeStatus.error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + state.errorMessage ?? AppLocalizations.of(context)!.error, + ), + ), + ); + } + }, + ), + BlocListener< + DefaultPreparationSpareTimeFormBloc, + DefaultPreparationSpareTimeFormState + >( + listenWhen: (previous, current) => + current.status == DefaultPreparationSpareTimeStatus.success && + current.preparation != null && + previous.preparation != current.preparation, + listener: (context, state) { + context.read().add( + PreparationFormEditRequested( + preparationEntity: state.preparation!, + ), + ); + }, + ), + ], child: Scaffold( appBar: AppBar( elevation: 0, @@ -83,19 +109,20 @@ class _PreparationSpareTimeEditView extends StatelessWidget { previous.isValid != current.isValid, builder: (context, preparationState) { return TextButton( - onPressed: - state2.isReadyForEditing && preparationState.isValid + onPressed: state2.canSubmit && preparationState.isValid ? () { + final currentPreparationState = context + .read() + .state; context .read() .add( FormSubmitted( note: '', - preparation: preparationState + preparation: currentPreparationState .toPreparationEntity(), ), ); - context.pop(); } : null, child: Text(AppLocalizations.of(context)!.ok), @@ -134,7 +161,7 @@ class _PreparationSpareTimeEditBody extends StatelessWidget { duration: const Duration(milliseconds: 180), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, - child: state.isReadyForEditing + child: state.hasEditableData ? _PreparationSpareTimeEditContent( key: const ValueKey('preparation_spare_time_form'), spareTime: state.spareTime!, @@ -225,8 +252,16 @@ class _SpareTimeSection extends StatelessWidget { } extension on DefaultPreparationSpareTimeFormState { - bool get isReadyForEditing => - status == DefaultPreparationSpareTimeStatus.success && + bool get hasEditableData => + (status == DefaultPreparationSpareTimeStatus.success || + status == DefaultPreparationSpareTimeStatus.submitting || + status == DefaultPreparationSpareTimeStatus.error) && + spareTime != null && + preparation != null; + + bool get canSubmit => + (status == DefaultPreparationSpareTimeStatus.success || + status == DefaultPreparationSpareTimeStatus.error) && spareTime != null && preparation != null; } diff --git a/test/data/data_sources/preparation_template_remote_data_source_test.dart b/test/data/data_sources/preparation_template_remote_data_source_test.dart new file mode 100644 index 00000000..8717005b --- /dev/null +++ b/test/data/data_sources/preparation_template_remote_data_source_test.dart @@ -0,0 +1,103 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/core/constants/endpoint.dart'; +import 'package:on_time_front/data/data_sources/preparation_template_remote_data_source.dart'; +import 'package:on_time_front/data/models/preparation_template_model.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; + +import '../../helpers/mock.mocks.dart'; + +void main() { + late Dio dio; + late PreparationTemplateRemoteDataSourceImpl dataSource; + + setUp(() { + dio = MockAppDio(); + dataSource = PreparationTemplateRemoteDataSourceImpl(dio); + }); + + test('gets active preparation templates', () async { + when(dio.get(Endpoint.preparationTemplates)).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: Endpoint.preparationTemplates), + data: { + 'status': 'success', + 'data': [ + { + 'templateId': 'template-1', + 'templateName': 'Work', + 'createdAt': '2026-05-14T02:10:00Z', + 'updatedAt': '2026-05-14T02:10:00Z', + 'deletedAt': null, + 'preparations': [], + }, + ], + }, + ), + ); + + final templates = await dataSource.getPreparationTemplates(); + + expect(templates.single.id, 'template-1'); + expect(templates.single.name, 'Work'); + }); + + test('creates preparation template with ordered steps', () async { + final request = UpsertPreparationTemplateRequestModel.fromValues( + templateId: 'template-1', + templateName: 'Work', + preparation: const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Pack laptop', + preparationTime: Duration(minutes: 5), + ), + ], + ), + ).toJson(); + + when(dio.post(Endpoint.preparationTemplates, data: request)).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: Endpoint.preparationTemplates), + ), + ); + + await dataSource.createPreparationTemplate( + templateId: 'template-1', + templateName: 'Work', + preparation: const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Pack laptop', + preparationTime: Duration(minutes: 5), + ), + ], + ), + ); + + verify(dio.post(Endpoint.preparationTemplates, data: request)).called(1); + }); + + test('deletes preparation template by id', () async { + when(dio.delete(Endpoint.preparationTemplateById('template-1'))).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions( + path: Endpoint.preparationTemplateById('template-1'), + ), + ), + ); + + await dataSource.deletePreparationTemplate('template-1'); + + verify( + dio.delete(Endpoint.preparationTemplateById('template-1')), + ).called(1); + }); +} diff --git a/test/data/models/preparation_template_model_test.dart b/test/data/models/preparation_template_model_test.dart new file mode 100644 index 00000000..3b05d723 --- /dev/null +++ b/test/data/models/preparation_template_model_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/models/ordered_preparation_step_model.dart'; +import 'package:on_time_front/data/models/preparation_template_model.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; + +void main() { + test('ordered preparation steps serialize zero-based orderIndex', () { + final preparation = PreparationEntity( + preparationStepList: [ + const PreparationStepEntity( + id: 'prep-1', + preparationName: 'Pack laptop', + preparationTime: Duration(minutes: 5), + nextPreparationId: 'prep-2', + ), + const PreparationStepEntity( + id: 'prep-2', + preparationName: 'Shower', + preparationTime: Duration(minutes: 15), + ), + ], + ); + + final json = OrderedPreparationStepModel.fromPreparationEntity( + preparation, + ).map((step) => step.toJson()).toList(); + + expect(json, [ + { + 'preparationId': 'prep-1', + 'preparationName': 'Pack laptop', + 'preparationTime': 5, + 'orderIndex': 0, + }, + { + 'preparationId': 'prep-2', + 'preparationName': 'Shower', + 'preparationTime': 15, + 'orderIndex': 1, + }, + ]); + }); + + test( + 'template response maps ordered steps back to linked preparation entity', + () { + final entity = PreparationTemplateModel.fromJson({ + 'templateId': 'template-1', + 'templateName': 'Work', + 'createdAt': '2026-05-14T02:10:00Z', + 'updatedAt': '2026-05-14T02:11:00Z', + 'deletedAt': null, + 'preparations': [ + { + 'preparationId': 'prep-2', + 'preparationName': 'Shower', + 'preparationTime': 15, + 'orderIndex': 1, + }, + { + 'preparationId': 'prep-1', + 'preparationName': 'Pack laptop', + 'preparationTime': 5, + 'orderIndex': 0, + }, + ], + }).toEntity(); + + expect(entity.id, 'template-1'); + expect(entity.name, 'Work'); + expect(entity.isDeleted, isFalse); + expect(entity.preparation.preparationStepList.first.id, 'prep-1'); + expect( + entity.preparation.preparationStepList.first.nextPreparationId, + 'prep-2', + ); + expect( + entity.preparation.preparationStepList.last.nextPreparationId, + isNull, + ); + }, + ); + + test('template upsert request serializes full replacement payload', () { + final request = UpsertPreparationTemplateRequestModel.fromValues( + templateId: 'template-1', + templateName: 'Work', + preparation: const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Pack laptop', + preparationTime: Duration(minutes: 5), + ), + ], + ), + ); + + expect(request.toJson(), { + 'templateId': 'template-1', + 'templateName': 'Work', + 'preparations': [ + { + 'preparationId': 'prep-1', + 'preparationName': 'Pack laptop', + 'preparationTime': 5, + 'orderIndex': 0, + }, + ], + }); + }); +} diff --git a/test/data/models/schedule_preparation_contract_test.dart b/test/data/models/schedule_preparation_contract_test.dart new file mode 100644 index 00000000..dabba373 --- /dev/null +++ b/test/data/models/schedule_preparation_contract_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/models/create_schedule_request_model.dart'; +import 'package:on_time_front/data/models/get_schedule_response_model.dart'; +import 'package:on_time_front/data/models/update_schedule_request_model.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; + +void main() { + final schedule = ScheduleEntity( + id: 'schedule-1', + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Morning meeting', + scheduleTime: DateTime(2026, 6, 1, 9, 30), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: 'Bring laptop', + ); + + test('create schedule omits preparation fields for default source', () { + final json = CreateScheduleRequestModel.fromEntity(schedule).toJson(); + + expect(json.containsKey('preparationTemplateId'), isFalse); + expect(json.containsKey('customPreparations'), isFalse); + }); + + test('create schedule serializes template source from template id', () { + final json = CreateScheduleRequestModel.fromEntity( + schedule.copyWith( + preparationMode: SchedulePreparationMode.template, + preparationTemplateId: 'template-1', + ), + ).toJson(); + + expect(json['preparationTemplateId'], 'template-1'); + expect(json.containsKey('customPreparations'), isFalse); + }); + + test('create schedule serializes custom ordered preparations', () { + final json = CreateScheduleRequestModel.fromEntity( + schedule.copyWith( + preparationMode: SchedulePreparationMode.custom, + customPreparations: const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Pack laptop', + preparationTime: Duration(minutes: 5), + ), + ], + ), + ), + ).toJson(); + + expect(json.containsKey('preparationTemplateId'), isFalse); + expect(json['customPreparations'], [ + { + 'preparationId': 'prep-1', + 'preparationName': 'Pack laptop', + 'preparationTime': 5, + 'orderIndex': 0, + }, + ]); + }); + + test('update schedule preserves preparation source by default', () { + final json = UpdateScheduleRequestModel.fromEntity( + schedule.copyWith( + preparationMode: SchedulePreparationMode.template, + preparationTemplateId: 'template-1', + ), + ).toJson(); + + expect(json.containsKey('preparationMode'), isFalse); + expect(json.containsKey('preparationTemplateId'), isFalse); + expect(json.containsKey('customPreparations'), isFalse); + }); + + test('update schedule includes preparation source when requested', () { + final json = UpdateScheduleRequestModel.fromEntity( + schedule.copyWith( + preparationMode: SchedulePreparationMode.template, + preparationTemplateId: 'template-1', + ), + includePreparationSource: true, + ).toJson(); + + expect(json['preparationMode'], 'TEMPLATE'); + expect(json['preparationTemplateId'], 'template-1'); + expect(json.containsKey('customPreparations'), isFalse); + }); + + test('schedule response parses preparation metadata and frozen flag', () { + final entity = GetScheduleResponseModel.fromJson({ + 'scheduleId': 'schedule-1', + 'placeId': 'place-1', + 'placeName': 'Office', + 'scheduleName': 'Morning meeting', + 'scheduleTime': '2026-06-01T09:30:00', + 'moveTime': 20, + 'scheduleSpareTime': 10, + 'scheduleNote': '', + 'startedAt': '2026-06-01T08:30:00Z', + 'preparationMode': 'TEMPLATE', + 'preparationTemplateId': 'template-1', + 'preparationTemplateName': 'Work', + 'preparationTemplateDeleted': true, + }).toEntity(); + + expect(entity.place.placeName, 'Office'); + expect(entity.preparationMode, SchedulePreparationMode.template); + expect(entity.preparationTemplateId, 'template-1'); + expect(entity.preparationTemplateName, 'Work'); + expect(entity.preparationTemplateDeleted, isTrue); + expect(entity.preparationFrozen, isTrue); + expect(entity.isStarted, isTrue); + }); +} diff --git a/test/data/repositories/preparation_repository_impl_test.dart b/test/data/repositories/preparation_repository_impl_test.dart index 8634e693..eb36f7aa 100644 --- a/test/data/repositories/preparation_repository_impl_test.dart +++ b/test/data/repositories/preparation_repository_impl_test.dart @@ -44,8 +44,9 @@ void main() { nextPreparationId: null, ); - final tPreparationEntity = - PreparationEntity(preparationStepList: [tPreparationStep]); + final tPreparationEntity = PreparationEntity( + preparationStepList: [tPreparationStep], + ); setUp(() { mockPreparationRemoteDataSource = MockPreparationRemoteDataSource(); @@ -147,29 +148,67 @@ void main() { group('updatePreparation', () { test('should call updatePreparation on remote data source', () async { // Arrange - when(mockPreparationRemoteDataSource - .updateDefaultPreparation(tPreparationEntity)) - .thenAnswer((_) async {}); + when( + mockPreparationRemoteDataSource.updateDefaultPreparation( + tPreparationEntity, + ), + ).thenAnswer((_) async {}); + when( + mockPreparationRemoteDataSource.getDefualtPreparation(), + ).thenAnswer((_) async => tPreparationEntity); // Act await preparationRepository.updateDefaultPreparation(tPreparationEntity); // Assert - verify(mockPreparationRemoteDataSource - .updateDefaultPreparation(tPreparationEntity)) - .called(1); + verify( + mockPreparationRemoteDataSource.updateDefaultPreparation( + tPreparationEntity, + ), + ).called(1); + verify(mockPreparationRemoteDataSource.getDefualtPreparation()).called(1); verifyNoMoreInteractions(mockPreparationRemoteDataSource); }); + test( + 'should throw when backend does not persist updated preparation', + () async { + final persistedPreparation = PreparationEntity( + preparationStepList: [ + tPreparationStep.copyWith( + preparationTime: const Duration(minutes: 5), + ), + ], + ); + when( + mockPreparationRemoteDataSource.updateDefaultPreparation( + tPreparationEntity, + ), + ).thenAnswer((_) async {}); + when( + mockPreparationRemoteDataSource.getDefualtPreparation(), + ).thenAnswer((_) async => persistedPreparation); + + final call = preparationRepository.updateDefaultPreparation( + tPreparationEntity, + ); + + expect(call, throwsA(isA())); + }, + ); + test('should throw an exception if remote data source fails', () async { // Arrange - when(mockPreparationRemoteDataSource - .updateDefaultPreparation(tPreparationEntity)) - .thenThrow(Exception()); + when( + mockPreparationRemoteDataSource.updateDefaultPreparation( + tPreparationEntity, + ), + ).thenThrow(Exception()); // Act - final call = - preparationRepository.updateDefaultPreparation(tPreparationEntity); + final call = preparationRepository.updateDefaultPreparation( + tPreparationEntity, + ); // Assert expect(call, throwsException); diff --git a/test/data/repositories/schedule_repository_impl_stream_test.dart b/test/data/repositories/schedule_repository_impl_stream_test.dart index 26e39e56..f2aa5c89 100644 --- a/test/data/repositories/schedule_repository_impl_stream_test.dart +++ b/test/data/repositories/schedule_repository_impl_stream_test.dart @@ -68,7 +68,10 @@ class FakeScheduleRemoteDataSource implements ScheduleRemoteDataSource { } @override - Future updateSchedule(ScheduleEntity schedule) { + Future updateSchedule( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) { return updateScheduleHandler(schedule); } } diff --git a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart index 4d0734ab..ce345d6f 100644 --- a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart +++ b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -69,18 +71,112 @@ void main() { expect(find.text('Coffee'), findsOneWidget); expect(find.text('15분'), findsOneWidget); }); + + testWidgets('save failure keeps editor open and shows error', (tester) async { + preparationStore.updateDefaultHandler = (_) async { + throw Exception('save failed'); + }; + + await _pumpScreen(tester); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.tap(find.byType(TextButton).first); + await tester.pumpAndSettle(); + + expect(find.byType(PreparationSpareTimeEditScreen), findsOneWidget); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.textContaining('save failed'), findsOneWidget); + }); + + testWidgets('save waits for completion before navigating back', ( + tester, + ) async { + final completer = Completer(); + preparationStore.updateDefaultHandler = (_) => completer.future; + + await _pumpRoutedScreen(tester); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton).first); + await tester.pump(); + + expect(find.byType(PreparationSpareTimeEditScreen), findsOneWidget); + + completer.complete(); + await tester.pumpAndSettle(); + + expect(find.byType(PreparationSpareTimeEditScreen), findsNothing); + expect(find.text('open'), findsOneWidget); + }); + + testWidgets('changed preparation step time is submitted', (tester) async { + await _pumpScreen(tester); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final context = tester.element(find.byType(TextFormField).first); + context.read().add( + const PreparationFormPreparationStepTimeChanged( + index: 0, + preparationStepTime: Duration(minutes: 15), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton).first); + await tester.pumpAndSettle(); + + expect( + preparationStore + .updatedPreparation! + .preparationStepList + .single + .preparationTime, + const Duration(minutes: 15), + ); + }); } Future _pumpScreen(WidgetTester tester) async { await tester.pumpWidget( - MaterialApp( - theme: themeData, - locale: const Locale('ko'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: BlocProvider.value( - value: _StubAuthBloc(), - child: const PreparationSpareTimeEditScreen(), + BlocProvider.value( + value: _StubAuthBloc(), + child: MaterialApp( + theme: themeData, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const PreparationSpareTimeEditScreen(), + ), + ), + ); +} + +Future _pumpRoutedScreen(WidgetTester tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: _StubAuthBloc(), + child: MaterialApp( + theme: themeData, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + return TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PreparationSpareTimeEditScreen(), + ), + ); + }, + child: const Text('open'), + ); + }, + ), ), ), ); @@ -92,6 +188,8 @@ class _FakePreparationStore { PreparationEntity defaultPreparation; PreparationEntity? updatedPreparation; Duration? updatedSpareTime; + Future Function(PreparationEntity preparationEntity)? + updateDefaultHandler; int loadUserCount = 0; } @@ -113,6 +211,11 @@ class _FakeUpdateDefaultPreparationUseCase extends Mock @override Future call(PreparationEntity preparationEntity) async { + final handler = store.updateDefaultHandler; + if (handler != null) { + await handler(preparationEntity); + return; + } store.updatedPreparation = preparationEntity; } } diff --git a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart index 0b7de0d7..93b57b47 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart @@ -92,7 +92,10 @@ class StubUpdateScheduleUseCase implements UpdateScheduleUseCase { final Future Function(ScheduleEntity schedule) handler; @override - Future call(ScheduleEntity schedule) => handler(schedule); + Future call( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) => handler(schedule); } class StubUpdatePreparationByScheduleIdUseCase diff --git a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart index 469fe4a4..01e9826b 100644 --- a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart +++ b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart @@ -106,7 +106,10 @@ class StubUpdateScheduleUseCase implements UpdateScheduleUseCase { Future Function(ScheduleEntity schedule) handler; @override - Future call(ScheduleEntity schedule) => handler(schedule); + Future call( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) => handler(schedule); } class StubUpdatePreparationByScheduleIdUseCase