diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt index 83583b4..0dfa05f 100644 --- a/fastlane/metadata/ko/release_notes.txt +++ b/fastlane/metadata/ko/release_notes.txt @@ -1,4 +1,3 @@ 이번 업데이트에서 다음 내용이 개선되었습니다. -• 세탁 진행 상태와 알림 목록이 더 정확하게 갱신되도록 개선했습니다. -• 예약 중 다른 기기를 눌렀을 때 엉뚱한 기기가 예약되던 문제를 수정했습니다. +• 예약 실패 시 앱이 비정상적으로 종료될 수 있는 문제를 개선했습니다. diff --git a/lib/core/errors/app_exception.dart b/lib/core/errors/app_exception.dart index 4e6fc70..4336062 100644 --- a/lib/core/errors/app_exception.dart +++ b/lib/core/errors/app_exception.dart @@ -1,5 +1,9 @@ import 'package:dio/dio.dart'; +abstract interface class UserFacingException implements Exception { + String get userMessage; +} + class AppException { AppException({ required this.message, @@ -12,6 +16,13 @@ class AppException { final String? debugMessage; factory AppException.from(Object? error) { + if (error is UserFacingException) { + return AppException( + message: error.userMessage, + debugMessage: error.toString(), + ); + } + if (error is DioException) { return AppException._fromDioException(error); } @@ -44,7 +55,7 @@ class AppException { type == DioExceptionType.receiveTimeout || type == DioExceptionType.sendTimeout || type == DioExceptionType.connectionTimeout || - statusCode == null) { + exception.response == null) { return AppException( message: '네트워크 연결을 확인해주세요.', statusCode: statusCode, @@ -85,7 +96,7 @@ class AppException { ); } - if (statusCode >= 500) { + if (statusCode != null && statusCode >= 500) { return AppException( message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', statusCode: statusCode, diff --git a/lib/features/report/presentation/providers/report_provider.dart b/lib/features/report/presentation/providers/report_provider.dart index 6eb745d..e39f11c 100644 --- a/lib/features/report/presentation/providers/report_provider.dart +++ b/lib/features/report/presentation/providers/report_provider.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:washer/core/utils/app_logger.dart'; import 'package:washer/features/report/data/data_sources/remote/report_remote_data_source.dart'; @@ -52,22 +51,6 @@ class ReportNotifier extends AsyncNotifier { } } -String reportErrorMessage(Object? error) { - const fallback = '고장 신고에 실패했습니다. 다시 시도해 주세요.'; - if (error is! DioException || error.response?.data == null) { - return fallback; - } - - final response = error.response!.data; - if (response is Map && - response['message'] is String && - (response['message'] as String).isNotEmpty) { - return response['message'] as String; - } - - return fallback; -} - final reportProvider = AsyncNotifierProvider( ReportNotifier.new, ); diff --git a/lib/features/reservation/presentation/providers/reservation_action_provider.dart b/lib/features/reservation/presentation/providers/reservation_action_provider.dart index 71e8d75..4fafd01 100644 --- a/lib/features/reservation/presentation/providers/reservation_action_provider.dart +++ b/lib/features/reservation/presentation/providers/reservation_action_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:washer/core/constants/durations.dart'; import 'package:washer/core/utils/app_logger.dart'; @@ -10,27 +9,17 @@ import 'package:washer/features/reservation/data/data_sources/remote/reservation import 'package:washer/features/reservation/data/data_sources/remote/reservation_status_remote_data_source.dart'; import 'package:washer/features/reservation/data/models/local/active_reservation_model.dart'; import 'package:washer/features/reservation/data/models/local/laundry_machine_model.dart'; +import 'package:washer/features/reservation/presentation/providers/reservation_exceptions.dart'; import 'package:washer/features/reservation/presentation/providers/reservation_penalty_provider.dart'; import 'package:washer/features/reservation/presentation/providers/reservation_status_provider.dart'; import 'package:washer/features/reservation/presentation/providers/reservation_sync_controller.dart'; -/// 예약 요청 직전 GET으로 확인한 결과, 이미 예약/사용 중이라 예약할 수 없는 경우. -class AlreadyReservedException implements Exception { - const AlreadyReservedException(); -} - -/// 취소 패널티 기간이라 서버 요청 없이 클라이언트에서 예약을 막은 경우. -class ReservationPenaltyException implements Exception { - const ReservationPenaltyException(this.expiresAt); - - final DateTime expiresAt; -} - class ReservationActionNotifier extends AsyncNotifier { /// 진행 중인 요청을 대상(기기/예약) 단위로 보관한다. /// 키 없이 단일 슬롯에 담으면 다른 대상의 요청에 합류해 그 결과가 /// 이 호출의 반환값이 된다(#261). final Map> _inflight = {}; + bool _didWaitForBuild = false; @override Future build() async => null; @@ -45,12 +34,12 @@ class ReservationActionNotifier extends AsyncNotifier { Future _reserveInternal({ required int machineId, }) async { - // build()이 끝난 뒤 상태를 바꿔야, 동기 경로에서 던진 예외 상태가 - // 뒤늦게 끝난 build 결과(null)로 덮이지 않는다. - await future; - state = const AsyncLoading(); - try { + // build()이 끝난 뒤 상태를 바꿔야, 동기 경로에서 던진 예외 상태가 + // 뒤늦게 끝난 build 결과(null)로 덮이지 않는다. + await _waitForInitialBuild(); + state = const AsyncLoading(); + // 취소 패널티 기간이면 서버로 요청을 보내지 않고 클라이언트에서 곧바로 막는다. final penaltyExpiry = ref.read(reservationPenaltyProvider); if (penaltyExpiry != null) { @@ -99,6 +88,15 @@ class ReservationActionNotifier extends AsyncNotifier { } } + Future _waitForInitialBuild() async { + if (_didWaitForBuild) { + return; + } + + await future; + _didWaitForBuild = true; + } + Future _findMachine(int machineId) async { final latestStatus = await ref .read(homeRemoteDataSourceProvider) @@ -169,47 +167,18 @@ class ReservationActionNotifier extends AsyncNotifier { final request = action(); _inflight[key] = request; - request.whenComplete(() => _inflight.remove(key)); + unawaited( + request.then( + (_) => _inflight.remove(key), + onError: (Object _, StackTrace __) { + _inflight.remove(key); + }, + ), + ); return request; } } -String reservationActionErrorMessage( - Object? error, { - required String fallback, -}) { - if (error is! DioException || error.response?.data == null) { - return fallback; - } - - final response = error.response!.data; - if (response is Map && - response['message'] is String && - (response['message'] as String).isNotEmpty) { - return response['message'] as String; - } - - return fallback; -} - -/// 예약 시도 실패를 사용자에게 보여줄 문구로 변환합니다. -/// -/// 사전 조회 단계에서 이미 예약/사용 중으로 확인된 경우는 별도 안내로, -/// 그 외에는 서버 메시지(없으면 기본 문구)를 사용합니다. -String reserveFailureMessage(Object? error) { - if (error is ReservationPenaltyException) { - final remaining = error.expiresAt.difference(DateTime.now()); - final minutes = remaining.inMinutes; - return minutes >= 1 - ? '예약이 제한된 상태입니다. 약 $minutes분 후 다시 시도해주세요.' - : '예약이 제한된 상태입니다. 잠시 후 다시 시도해주세요.'; - } - if (error is AlreadyReservedException) { - return '이미 예약된 기기입니다.'; - } - return '예약 실패: ${reservationActionErrorMessage(error, fallback: '예약에 실패했습니다. 다시 시도해주세요.')}'; -} - final reservationActionProvider = AsyncNotifierProvider( ReservationActionNotifier.new, diff --git a/lib/features/reservation/presentation/providers/reservation_exceptions.dart b/lib/features/reservation/presentation/providers/reservation_exceptions.dart new file mode 100644 index 0000000..3077868 --- /dev/null +++ b/lib/features/reservation/presentation/providers/reservation_exceptions.dart @@ -0,0 +1,25 @@ +import 'package:washer/core/errors/app_exception.dart'; + +/// 예약 요청 직전 조회에서 이미 예약 또는 사용 중인 기기로 확인된 경우. +class AlreadyReservedException implements UserFacingException { + const AlreadyReservedException(); + + @override + String get userMessage => '이미 예약된 기기입니다.'; +} + +/// 취소 패널티 기간이라 서버 요청 없이 예약을 막은 경우. +class ReservationPenaltyException implements UserFacingException { + const ReservationPenaltyException(this.expiresAt); + + final DateTime expiresAt; + + @override + String get userMessage { + final remaining = expiresAt.difference(DateTime.now()); + final minutes = remaining.inMinutes; + return minutes >= 1 + ? '예약이 제한된 상태입니다. 약 $minutes분 후 다시 시도해주세요.' + : '예약이 제한된 상태입니다. 잠시 후 다시 시도해주세요.'; + } +} diff --git a/lib/shared/ui/dialog/dialog_action.dart b/lib/shared/ui/dialog/dialog_action.dart index 65f3850..c77c487 100644 --- a/lib/shared/ui/dialog/dialog_action.dart +++ b/lib/shared/ui/dialog/dialog_action.dart @@ -10,15 +10,15 @@ import 'package:washer/core/utils/app_logger.dart'; /// 액션 고유 정보만 담는다. 액션 종류별 정의는 `DialogActions` 팩토리 한 곳에 /// 모아 두고, 호출부는 그것을 [runDialogAction]에 넘기기만 한다. /// -/// [run]·[failureMessage]는 위젯의 `ref`가 아니라 lifecycle과 무관한 -/// [ProviderContainer]를 받는다. 따라서 pop(=widget dispose) 이후 실행돼도 -/// "bad state" 오류가 나지 않으며, 호출부가 notifier를 미리 캡처할 필요도 없다. +/// [run]은 위젯의 `ref`가 아니라 lifecycle과 무관한 [ProviderContainer]를 +/// 받는다. 따라서 pop(=widget dispose) 이후 실행돼도 "bad state" 오류가 나지 +/// 않으며, 호출부가 notifier를 미리 캡처할 필요도 없다. class DialogAction { const DialogAction({ required this.run, required this.isSuccess, required this.successMessage, - required this.failureMessage, + required this.fallbackMessage, required this.logName, this.failureError, this.showLoading = false, @@ -27,7 +27,7 @@ class DialogAction { final Future Function(ProviderContainer container) run; final bool Function(R result) isSuccess; final String successMessage; - final String Function(ProviderContainer container) failureMessage; + final String fallbackMessage; final String logName; final Object? Function(ProviderContainer container)? failureError; @@ -78,7 +78,7 @@ Future runDialogAction( messenger.showErrorSnackBar(failureError); } else { messenger.showSnackBar( - SnackBar(content: Text(action.failureMessage(container))), + SnackBar(content: Text(action.fallbackMessage)), ); } } diff --git a/lib/shared/ui/dialog/dialog_actions.dart b/lib/shared/ui/dialog/dialog_actions.dart index 6bfb22f..456e21c 100644 --- a/lib/shared/ui/dialog/dialog_actions.dart +++ b/lib/shared/ui/dialog/dialog_actions.dart @@ -24,9 +24,9 @@ abstract final class DialogActions { successMessage: '$machineName 예약이 완료되었습니다\n' '$reservationExpiryMinutes분 동안 기기 연결을 확인합니다', - failureMessage: (container) => reserveFailureMessage( - container.read(reservationActionProvider).error, - ), + fallbackMessage: '예약에 실패했습니다. 다시 시도해주세요.', + failureError: (container) => + container.read(reservationActionProvider).error, logName: 'ReserveAction', showLoading: true, ); @@ -42,10 +42,7 @@ abstract final class DialogActions { ), isSuccess: (didCancel) => didCancel, successMessage: '예약이 취소되었습니다.', - failureMessage: (container) => reservationActionErrorMessage( - container.read(reservationActionProvider).error, - fallback: '예약 취소에 실패했습니다.', - ), + fallbackMessage: '예약 취소에 실패했습니다.', failureError: (container) => container.read(reservationActionProvider).error, logName: 'CancelReservationAction', @@ -66,8 +63,8 @@ abstract final class DialogActions { ), isSuccess: (didReport) => didReport, successMessage: '신고가 완료되었습니다.', - failureMessage: (container) => - reportErrorMessage(container.read(reportProvider).error), + fallbackMessage: '고장 신고에 실패했습니다. 다시 시도해 주세요.', + failureError: (container) => container.read(reportProvider).error, logName: 'ReportBrokenAction', ); } diff --git a/pubspec.yaml b/pubspec.yaml index 946001d..4f8d689 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.6+8 +version: 1.1.6+9 environment: sdk: ^3.9.2 diff --git a/test/features/report/report_test.dart b/test/features/report/report_test.dart index dfeac96..e94c908 100644 --- a/test/features/report/report_test.dart +++ b/test/features/report/report_test.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:washer/core/errors/app_exception.dart'; import 'package:washer/features/report/data/data_sources/remote/report_remote_data_source.dart'; import 'package:washer/features/report/presentation/providers/report_provider.dart'; @@ -68,7 +69,7 @@ void main() { expect(result, isFalse); expect( - reportErrorMessage(container.read(reportProvider).error), + AppException.from(container.read(reportProvider).error).message, '이미 접수된 신고입니다.', ); }); diff --git a/test/features/reservation/reservation_test.dart b/test/features/reservation/reservation_test.dart index 198b48f..65651fc 100644 --- a/test/features/reservation/reservation_test.dart +++ b/test/features/reservation/reservation_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:washer/core/errors/app_exception.dart'; import 'package:washer/core/enums/machine_state.dart'; import 'package:washer/core/utils/date_time_formatter.dart'; import 'package:washer/features/reservation/data/data_sources/remote/reservation_status_remote_data_source.dart'; @@ -14,6 +15,7 @@ import 'package:washer/features/reservation/data/data_sources/remote/reservation import 'package:washer/features/reservation/data/models/remote/cancel_reservation_response.dart'; import 'package:washer/features/reservation/data/models/remote/confirm_reservation_response.dart'; import 'package:washer/features/reservation/presentation/providers/reservation_action_provider.dart'; +import 'package:washer/features/reservation/presentation/providers/reservation_exceptions.dart'; import 'package:washer/features/reservation/presentation/providers/reservation_penalty_provider.dart'; class FakeReservationRemoteDataSource implements ReservationRemoteDataSource { @@ -539,6 +541,62 @@ void main() { ); }); + test('#267: previous reservation failure is not rethrown on retry', () 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: [ + MachineModel( + machineId: 83, + name: 'Washer-4F-L1', + type: 'WASHER', + status: 'NORMAL', + availability: 'RESERVED', + reservationId: 114, + ), + ], + totalCount: 1, + ), + ), + ), + ], + ); + addTearDown(container.dispose); + + final firstResult = await container + .read(reservationActionProvider.notifier) + .reserve(machineId: 83); + + expect(firstResult, isNull); + expect( + container.read(reservationActionProvider).error, + isA(), + ); + + await expectLater( + container + .read(reservationActionProvider.notifier) + .reserve(machineId: 83), + completion(isNull), + ); + }); + + test('#267: AlreadyReservedException is mapped to user message', () { + expect( + AppException.from(const AlreadyReservedException()).message, + '이미 예약된 기기입니다.', + ); + }); + test('#256: 첫 조회에서만 사용중으로 보이면 재확인 후 예약을 진행한다', () async { var callCount = 0; const reservedResponse = MachineStatusResponse( @@ -637,10 +695,9 @@ void main() { expect(reservationDataSource.cancelledId, 114); expect(result, isFalse); expect( - reservationActionErrorMessage( + AppException.from( container.read(reservationActionProvider).error, - fallback: '예약 취소에 실패했습니다.', - ), + ).message, '예약 취소 시간이 지났습니다.', ); });