diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt index 47fd77e..5a310a7 100644 --- a/fastlane/metadata/ko/release_notes.txt +++ b/fastlane/metadata/ko/release_notes.txt @@ -3,3 +3,4 @@ • 로그인을 앱 내부 화면 대신 기기의 브라우저로 진행하도록 변경해 더 안전하게 로그인할 수 있습니다. • 예약을 취소한 직후 같은 기기를 다시 예약할 때 이미 사용 중이라고 잘못 안내되던 문제를 개선했습니다. • 서버 오류 발생 시 더 정확한 안내 메시지가 표시되도록 개선했습니다. +• 세탁 진행 상태와 알림 목록이 더 정확하게 갱신되도록 개선했습니다. diff --git a/lib/features/alarm/data/data_sources/alarm_data_source.dart b/lib/features/alarm/data/data_sources/alarm_data_source.dart index 31db9f9..26ca90d 100644 --- a/lib/features/alarm/data/data_sources/alarm_data_source.dart +++ b/lib/features/alarm/data/data_sources/alarm_data_source.dart @@ -43,8 +43,9 @@ class AlarmDataSourceImpl implements AlarmDataSource { return const AlarmListResponse(data: []); } - // 서버 응답은 `{ notifications: [...] }` 형태로 봉투가 없다. - return AlarmListResponse.fromJson(castJsonMap(response.data)); + final body = castJsonMap(response.data); + final data = body.containsKey('data') ? extractDataMap(body) : body; + return AlarmListResponse.fromJson(data); } @override diff --git a/lib/features/reservation/presentation/providers/reservation_action_provider.dart b/lib/features/reservation/presentation/providers/reservation_action_provider.dart index e5a2bd5..71e8d75 100644 --- a/lib/features/reservation/presentation/providers/reservation_action_provider.dart +++ b/lib/features/reservation/presentation/providers/reservation_action_provider.dart @@ -27,17 +27,18 @@ class ReservationPenaltyException implements Exception { } class ReservationActionNotifier extends AsyncNotifier { - Future? _reserveRequest; - Future? _cancelRequest; + /// 진행 중인 요청을 대상(기기/예약) 단위로 보관한다. + /// 키 없이 단일 슬롯에 담으면 다른 대상의 요청에 합류해 그 결과가 + /// 이 호출의 반환값이 된다(#261). + final Map> _inflight = {}; @override Future build() async => null; Future reserve({required int machineId}) { return _runSingleFlight( - currentRequest: _reserveRequest, - setRequest: (request) => _reserveRequest = request, - action: () => _reserveInternal(machineId: machineId), + 'reserve:$machineId', + () => _reserveInternal(machineId: machineId), ); } @@ -109,9 +110,8 @@ class ReservationActionNotifier extends AsyncNotifier { Future cancel({required int reservationId}) { return _runSingleFlight( - currentRequest: _cancelRequest, - setRequest: (request) => _cancelRequest = request, - action: () => _cancelInternal(reservationId: reservationId), + 'cancel:$reservationId', + () => _cancelInternal(reservationId: reservationId), ); } @@ -156,18 +156,20 @@ class ReservationActionNotifier extends AsyncNotifier { ref.read(reservationSyncControllerProvider).stopPolling(); } - Future _runSingleFlight({ - required Future? currentRequest, - required void Function(Future? request) setRequest, - required Future Function() action, - }) { + /// 같은 [key] 로 들어온 중복 요청만 하나로 합친다. + /// + /// 키가 다르면(다른 기기 예약, 다른 예약 취소) 각자 요청을 보낸다. + /// 합쳐 버리면 누르지 않은 대상의 결과가 반환값이 되어 호출부가 그것을 + /// 성공으로 처리한다(#261). + Future _runSingleFlight(String key, Future Function() action) { + final currentRequest = _inflight[key]; if (currentRequest != null) { - return currentRequest; + return currentRequest.then((value) => value as T); } final request = action(); - setRequest(request); - request.whenComplete(() => setRequest(null)); + _inflight[key] = request; + request.whenComplete(() => _inflight.remove(key)); return request; } } diff --git a/lib/features/reservation/presentation/providers/reservation_sync_controller.dart b/lib/features/reservation/presentation/providers/reservation_sync_controller.dart index cbdfe6b..1d26e5d 100644 --- a/lib/features/reservation/presentation/providers/reservation_sync_controller.dart +++ b/lib/features/reservation/presentation/providers/reservation_sync_controller.dart @@ -70,10 +70,8 @@ class ReservationSyncController { return; } - final hasPendingReservation = latest.any( - (reservation) => reservation.laundryStatus == LaundryStatus.reserved, - ); - if (!hasPendingReservation) { + final shouldKeepPolling = latest.any(_shouldKeepPolling); + if (!shouldKeepPolling) { stopPolling(); } @@ -117,4 +115,17 @@ class ReservationSyncController { return listEquals(current, latest); } + + bool _shouldKeepPolling(ActiveReservationModel reservation) { + if (reservation.laundryStatus == LaundryStatus.reserved) { + return true; + } + + return reservation.laundryStatus == LaundryStatus.inUse && + !_hasText(reservation.expectedCompletionTime); + } + + bool _hasText(String? value) { + return value != null && value.trim().isNotEmpty; + } } diff --git a/pubspec.yaml b/pubspec.yaml index 3c0957f..fec7370 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.5+6 +version: 1.1.5+7 environment: sdk: ^3.9.2 diff --git a/test/features/reservation/reservation_test.dart b/test/features/reservation/reservation_test.dart index 67d1933..198b48f 100644 --- a/test/features/reservation/reservation_test.dart +++ b/test/features/reservation/reservation_test.dart @@ -19,16 +19,23 @@ import 'package:washer/features/reservation/presentation/providers/reservation_p class FakeReservationRemoteDataSource implements ReservationRemoteDataSource { FakeReservationRemoteDataSource({ this.createdReservation = _reservedReservation, + this.createdReservationBuilder, this.cancelError, this.cancelResponse = _noPenaltyCancel, }); final ActiveReservationModel createdReservation; + + /// machineId 별로 다른 예약을 돌려줘야 하는 테스트용. + final ActiveReservationModel Function(int machineId)? + createdReservationBuilder; final Object? cancelError; final CancelReservationResponse cancelResponse; int? lastMachineId; String? lastStartTime; int? cancelledId; + final List createdMachineIds = []; + final List cancelledIds = []; @override Future createReservation({ @@ -37,12 +44,14 @@ class FakeReservationRemoteDataSource implements ReservationRemoteDataSource { }) async { lastMachineId = machineId; lastStartTime = startTime; - return createdReservation; + createdMachineIds.add(machineId); + return createdReservationBuilder?.call(machineId) ?? createdReservation; } @override Future cancelReservation({required int id}) async { cancelledId = id; + cancelledIds.add(id); final nextError = cancelError; if (nextError != null) { throw nextError; @@ -393,6 +402,100 @@ void main() { ]); }); + test('#261: 다른 기기를 연달아 누르면 각각 별도 요청이 나간다', () async { + final reservationDataSource = FakeReservationRemoteDataSource( + createdReservationBuilder: (machineId) => + _reservedReservation.copyWith(machineId: machineId), + ); + final container = ProviderContainer( + overrides: [ + reservationRemoteDataSourceProvider.overrideWith( + (ref) => reservationDataSource, + ), + reservationPenaltyProvider.overrideWith( + FakeReservationPenaltyNotifier.new, + ), + homeRemoteDataSourceProvider.overrideWith( + (ref) => FakeHomeRemoteDataSource( + machineStatusLoader: () async => + const MachineStatusResponse(machines: [], totalCount: 0), + ), + ), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(reservationActionProvider.notifier); + // 83 이 아직 진행 중인 상태에서 84 를 누른다. + final first = notifier.reserve(machineId: 83); + final second = notifier.reserve(machineId: 84); + final results = await Future.wait([first, second]); + + expect(reservationDataSource.createdMachineIds, [83, 84]); + expect(results[0]?.machineId, 83); + // 키가 없으면 84 호출이 83 의 결과를 그대로 받아 성공으로 처리된다. + expect(results[1]?.machineId, 84); + }); + + test('#261: 같은 기기를 연달아 누르면 요청은 한 번만 나간다', () async { + final reservationDataSource = FakeReservationRemoteDataSource(); + final container = ProviderContainer( + overrides: [ + reservationRemoteDataSourceProvider.overrideWith( + (ref) => reservationDataSource, + ), + reservationPenaltyProvider.overrideWith( + FakeReservationPenaltyNotifier.new, + ), + homeRemoteDataSourceProvider.overrideWith( + (ref) => FakeHomeRemoteDataSource( + machineStatusLoader: () async => + const MachineStatusResponse(machines: [], totalCount: 0), + ), + ), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(reservationActionProvider.notifier); + final results = await Future.wait([ + notifier.reserve(machineId: 83), + notifier.reserve(machineId: 83), + ]); + + expect(reservationDataSource.createdMachineIds, [83]); + expect(results[0], results[1]); + }); + + test('#261: 다른 예약을 연달아 취소하면 각각 별도 요청이 나간다', () async { + final reservationDataSource = FakeReservationRemoteDataSource(); + final container = ProviderContainer( + overrides: [ + reservationRemoteDataSourceProvider.overrideWith( + (ref) => reservationDataSource, + ), + reservationPenaltyProvider.overrideWith( + FakeReservationPenaltyNotifier.new, + ), + homeRemoteDataSourceProvider.overrideWith( + (ref) => FakeHomeRemoteDataSource( + machineStatusLoader: () async => + const MachineStatusResponse(machines: [], totalCount: 0), + ), + ), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(reservationActionProvider.notifier); + await Future.wait([ + notifier.cancel(reservationId: 114), + notifier.cancel(reservationId: 115), + ]); + + expect(reservationDataSource.cancelledIds, [114, 115]); + }); + test('예약 전 조회 결과 이미 예약된 기기면 요청을 보내지 않고 예외를 담는다', () async { final reservationDataSource = FakeReservationRemoteDataSource(); final container = ProviderContainer(