From bc2ac5869fc601128129fda5fea4ccb122972739 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 17:21:38 +0900 Subject: [PATCH 01/14] Update api-docs.json --- docs/api-docs.json | 984 +++++++++++++++++++++++++++++---------------- 1 file changed, 643 insertions(+), 341 deletions(-) diff --git a/docs/api-docs.json b/docs/api-docs.json index 1d296f5..78fedc1 100644 --- a/docs/api-docs.json +++ b/docs/api-docs.json @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Exploration", + "description": "우주 탐험(행성/지역 해금) API" + }, { "name": "TodoCategory", "description": "할 일 카테고리 CRUD API" @@ -115,82 +119,82 @@ "required": true }, "responses": { - "409": { - "description": "동일 ID 중복", + "201": { + "description": "생성 성공", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "TODO_ALREADY_EXISTS", - "message": "동일 ID의 할 일이 이미 존재합니다." + "$ref": "#/components/schemas/TodoResponse" } } } }, - "500": { - "description": "서버 내부 오류", + "404": { + "description": "카테고리 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "CATEGORY_NOT_FOUND", + "message": "해당 카테고리를 찾을 수 없습니다." } } } }, - "201": { - "description": "생성 성공", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TodoResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "404": { - "description": "카테고리 없음", + "409": { + "description": "동일 ID 중복", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "CATEGORY_NOT_FOUND", - "message": "해당 카테고리를 찾을 수 없습니다." + "code": "TODO_ALREADY_EXISTS", + "message": "동일 ID의 할 일이 이미 존재합니다." } } } }, - "400": { - "description": "입력값 검증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INVALID_INPUT_VALUE", - "message": "title: 비어있을 수 없습니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } }, - "401": { - "description": "인증 실패", + "400": { + "description": "입력값 검증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INVALID_INPUT_VALUE", + "message": "title: 비어있을 수 없습니다." } } } @@ -205,29 +209,29 @@ "description": "createdAt 오름차순", "operationId": "findAll_1", "responses": { - "401": { - "description": "인증 실패", + "200": { + "description": "조회 성공", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryResponse" + } } } } }, - "200": { - "description": "조회 성공", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CategoryResponse" - } + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -249,20 +253,6 @@ "required": true }, "responses": { - "500": { - "description": "서버 내부 오류", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." - } - } - } - }, "201": { "description": "생성 성공", "content": { @@ -287,30 +277,44 @@ } } }, - "400": { - "description": "입력값 검증 실패", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INVALID_INPUT_VALUE", - "message": "name: 비어있을 수 없습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + } + } + }, + "400": { + "description": "입력값 검증 실패", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "name: 비어있을 수 없습니다." } } } @@ -371,12 +375,16 @@ } ], "responses": { - "200": { - "description": "조회 성공", + "400": { + "description": "잘못된 query parameter", "content": { - "application/json": { + "*/*": { "schema": { - "$ref": "#/components/schemas/TimerSessionListResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "입력값이 유효하지 않습니다." } } } @@ -386,25 +394,17 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "$ref": "#/components/schemas/TimerSessionListResponse" } } } }, - "400": { - "description": "잘못된 query parameter", + "200": { + "description": "조회 성공", "content": { - "*/*": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INVALID_INPUT_VALUE", - "message": "입력값이 유효하지 않습니다." + "$ref": "#/components/schemas/TimerSessionListResponse" } } } @@ -439,30 +439,6 @@ "required": true }, "responses": { - "401": { - "description": "인증 필요", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 오류", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." - } - } - } - }, "400": { "description": "검증 실패", "content": { @@ -510,6 +486,40 @@ } } }, + "500": { + "description": "서버 오류", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/TimerSessionCreateResponse" + } + } + } + }, + "401": { + "description": "인증 필요", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "연결된 Todo가 본인 소유 아님 / 존재하지 않음", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "TODO_NOT_FOUND", + "message": "해당 할 일을 찾을 수 없습니다." + } + } + } + }, "201": { "description": "저장 성공", "content": { @@ -530,17 +540,63 @@ } } } - }, - "404": { - "description": "연결된 Todo가 본인 소유 아님 / 존재하지 않음", + } + } + } + }, + "/api/explorations/regions/{regionId}/unlock": { + "post": { + "tags": ["Exploration"], + "summary": "지역 해금", + "description": "연료를 소비하여 지역을 해금합니다(해금=클리어). 잔량 확인+차감+해금을 원자적으로 처리합니다.\n상위 행성의 모든 지역이 해금되면 planetCleared=true.\n\n에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PLANET_LOCKED, 404 REGION_NOT_FOUND\n", + "operationId": "unlockRegion", + "parameters": [ + { + "name": "regionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "TODO_NOT_FOUND", - "message": "해당 할 일을 찾을 수 없습니다." + "$ref": "#/components/schemas/RegionUnlockResponse" + } + } + } + } + } + } + }, + "/api/explorations/planets/{planetId}/unlock": { + "post": { + "tags": ["Exploration"], + "summary": "행성 해금", + "description": "연료를 소비하여 행성을 해금합니다. 선행 행성을 클리어해야 해금할 수 있습니다.\n\n에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PREREQUISITE_NOT_CLEARED, 404 PLANET_NOT_FOUND\n", + "operationId": "unlockPlanet", + "parameters": [ + { + "name": "planetId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PlanetUnlockResponse" } } } @@ -565,38 +621,38 @@ "required": true }, "responses": { - "500": { - "description": "서버 내부 오류.", + "400": { + "description": "요청 본문 형식 오류 (refreshToken 누락 등).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InternalServerError": { - "description": "InternalServerError", + "InvalidInputValue": { + "description": "InvalidInputValue", "value": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "INVALID_INPUT_VALUE", + "message": "refreshToken: Refresh Token은 필수입니다." } } } } } }, - "401": { - "description": "Refresh Token 이 만료되었거나, DB의 저장 해시와 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", + "500": { + "description": "서버 내부 오류.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidToken": { - "description": "InvalidToken", + "InternalServerError": { + "description": "InternalServerError", "value": { - "code": "INVALID_TOKEN", - "message": "인증 정보가 올바르지 않습니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -624,19 +680,19 @@ } } }, - "400": { - "description": "요청 본문 형식 오류 (refreshToken 누락 등).", + "401": { + "description": "Refresh Token 이 만료되었거나, DB의 저장 해시와 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidInputValue": { - "description": "InvalidInputValue", + "InvalidToken": { + "description": "InvalidToken", "value": { - "code": "INVALID_INPUT_VALUE", - "message": "refreshToken: Refresh Token은 필수입니다." + "code": "INVALID_TOKEN", + "message": "인증 정보가 올바르지 않습니다." } } } @@ -663,41 +719,41 @@ "required": true }, "responses": { - "500": { - "description": "서버 내부 오류.", + "204": { + "description": "로그아웃 처리 완료. 응답 본문 없음." + }, + "400": { + "description": "요청 본문 형식 오류 (refreshToken 누락 등).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InternalServerError": { - "description": "InternalServerError", + "InvalidInputValue": { + "description": "InvalidInputValue", "value": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "INVALID_INPUT_VALUE", + "message": "refreshToken: Refresh Token은 필수입니다." } } } } } }, - "204": { - "description": "로그아웃 처리 완료. 응답 본문 없음." - }, - "400": { - "description": "요청 본문 형식 오류 (refreshToken 누락 등).", + "500": { + "description": "서버 내부 오류.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidInputValue": { - "description": "InvalidInputValue", + "InternalServerError": { + "description": "InternalServerError", "value": { - "code": "INVALID_INPUT_VALUE", - "message": "refreshToken: Refresh Token은 필수입니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -724,25 +780,6 @@ "required": true }, "responses": { - "500": { - "description": "서버 내부 오류. 닉네임 생성 재시도 초과 등.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "InternalServerError": { - "description": "InternalServerError", - "value": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." - } - } - } - } - } - }, "200": { "description": "기존 회원 로그인 성공.", "content": { @@ -780,13 +817,41 @@ "summary": "신규 회원 가입", "description": "NewMember", "value": { - "memberId": 42, - "nickname": "용감한고양이7321", - "tokens": { - "accessToken": "eyJhbGciOiJIUzI1NiIs...", - "refreshToken": "eyJhbGciOiJIUzI1NiIs..." - }, - "isNewMember": true + "memberId": 42, + "nickname": "용감한고양이7321", + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs..." + }, + "isNewMember": true + } + } + } + } + } + }, + "400": { + "description": "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidInputValue": { + "summary": "필수 필드 누락", + "description": "InvalidInputValue", + "value": { + "code": "INVALID_INPUT_VALUE", + "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." + } + }, + "UnsupportedSocialType": { + "summary": "지원하지 않는 소셜 타입", + "description": "UnsupportedSocialType", + "value": { + "code": "UNSUPPORTED_SOCIAL_TYPE", + "message": "지원하지 않는 소셜 로그인 방식입니다." } } } @@ -812,28 +877,19 @@ } } }, - "400": { - "description": "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", + "500": { + "description": "서버 내부 오류. 닉네임 생성 재시도 초과 등.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidInputValue": { - "summary": "필수 필드 누락", - "description": "InvalidInputValue", - "value": { - "code": "INVALID_INPUT_VALUE", - "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." - } - }, - "UnsupportedSocialType": { - "summary": "지원하지 않는 소셜 타입", - "description": "UnsupportedSocialType", + "InternalServerError": { + "description": "InternalServerError", "value": { - "code": "UNSUPPORTED_SOCIAL_TYPE", - "message": "지원하지 않는 소셜 로그인 방식입니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -861,6 +917,9 @@ } ], "responses": { + "204": { + "description": "삭제 성공" + }, "404": { "description": "Todo 없음", "content": { @@ -875,33 +934,30 @@ } } }, - "500": { - "description": "서버 내부 오류", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "204": { - "description": "삭제 성공" - }, - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -935,16 +991,16 @@ "required": true }, "responses": { - "500": { - "description": "서버 내부 오류", + "404": { + "description": "Todo 없음 또는 다른 사용자 소유", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "TODO_NOT_FOUND", + "message": "해당 할 일을 찾을 수 없습니다." } } } @@ -977,16 +1033,16 @@ } } }, - "404": { - "description": "Todo 없음 또는 다른 사용자 소유", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "TODO_NOT_FOUND", - "message": "해당 할 일을 찾을 수 없습니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -1022,47 +1078,47 @@ } ], "responses": { - "500": { - "description": "서버 내부 오류", + "204": { + "description": "삭제 성공" + }, + "404": { + "description": "카테고리 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "CATEGORY_NOT_FOUND", + "message": "해당 카테고리를 찾을 수 없습니다." } } } }, - "404": { - "description": "카테고리 없음", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "CATEGORY_NOT_FOUND", - "message": "해당 카테고리를 찾을 수 없습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "204": { - "description": "삭제 성공" - }, - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -1095,20 +1151,6 @@ "required": true }, "responses": { - "500": { - "description": "서버 내부 오류", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." - } - } - } - }, "400": { "description": "입력값 검증 실패", "content": { @@ -1151,6 +1193,20 @@ } } }, + "500": { + "description": "서버 내부 오류", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + } + } + }, "200": { "description": "수정 성공", "content": { @@ -1181,75 +1237,75 @@ "required": true }, "responses": { - "500": { - "description": "서버 내부 오류.", + "200": { + "description": "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/UpdateNicknameResponse" }, "examples": { - "InternalServerError": { - "description": "InternalServerError", + "UpdateSuccess": { + "description": "UpdateSuccess", "value": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "nickname": "우주탐험가" } } } } } }, - "409": { - "description": "이미 다른 사용자가 사용 중인 닉네임.", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "DuplicatedNickname": { - "description": "DuplicatedNickname", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "DUPLICATED_NICKNAME", - "message": "이미 사용 중인 닉네임입니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } } } }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", + "409": { + "description": "이미 다른 사용자가 사용 중인 닉네임.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "DuplicatedNickname": { + "description": "DuplicatedNickname", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "DUPLICATED_NICKNAME", + "message": "이미 사용 중인 닉네임입니다." } } } } } }, - "200": { - "description": "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", + "500": { + "description": "서버 내부 오류.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateNicknameResponse" + "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UpdateSuccess": { - "description": "UpdateSuccess", + "InternalServerError": { + "description": "InternalServerError", "value": { - "nickname": "우주탐험가" + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -1285,20 +1341,6 @@ "description": "KST(Asia/Seoul) 기준 통계.\n\n### 응답 필드\n- `totalMinutes`, `sessionCount`: 오늘 (KST)\n- `streak`: 연속 공부 일수 (오늘 포함, KST)\n- `lifetimeMinutes`, `lifetimeSessionCount`: 회원의 전체 누적\n- `monthlyMinutes`: 이번 달 누적 (KST 1일 00:00 ~ 다음 달 1일 00:00)\n\n세션 0건 회원도 6개 필드 모두 `0`을 반환합니다 (null 금지).\n", "operationId": "getTodayStats", "responses": { - "401": { - "description": "인증 필요", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." - } - } - } - }, "200": { "description": "조회 성공", "content": { @@ -1316,6 +1358,16 @@ } } } + }, + "401": { + "description": "인증 필요", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/TodayStatsResponse" + } + } + } } } } @@ -1429,6 +1481,30 @@ } ], "responses": { + "400": { + "description": "잘못된 query parameter", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "type은 charge 또는 consume이어야 합니다." + } + } + } + }, + "401": { + "description": "인증 필요", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/FuelTransactionListResponse" + } + } + } + }, "200": { "description": "조회 성공", "content": { @@ -1461,44 +1537,68 @@ } } }, - "400": { - "description": "잘못된 query parameter", + "500": { + "description": "서버 오류", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INVALID_INPUT_VALUE", - "message": "type은 charge 또는 consume이어야 합니다." + "$ref": "#/components/schemas/FuelTransactionListResponse" } } } - }, - "401": { - "description": "인증 필요", + } + } + } + }, + "/api/explorations/planets": { + "get": { + "tags": ["Exploration"], + "summary": "행성 목록 조회", + "description": "전체 행성 목록과 유저의 해금/클리어 상태, 진행도를 반환합니다. 정렬: sortOrder 오름차순.", + "operationId": "getPlanets", + "responses": { + "200": { + "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "type": "array", + "items": { + "$ref": "#/components/schemas/PlanetResponse" + } } } } - }, - "500": { - "description": "서버 오류", + } + } + } + }, + "/api/explorations/planets/{planetId}/regions": { + "get": { + "tags": ["Exploration"], + "summary": "행성 하위 지역 목록 조회", + "description": "특정 행성의 하위 지역과 유저 해금 상태를 반환합니다. 행성이 없으면 404 PLANET_NOT_FOUND.", + "operationId": "getRegions", + "parameters": [ + { + "name": "planetId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "type": "array", + "items": { + "$ref": "#/components/schemas/RegionResponse" + } } } } @@ -1530,26 +1630,19 @@ } ], "responses": { - "200": { - "description": "정상 조회. 본문의 `available` 필드로 사용 가능 여부 판단.", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CheckNicknameResponse" + "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "Available": { - "summary": "사용 가능한 닉네임", - "description": "Available", - "value": { - "available": true - } - }, - "NotAvailable": { - "summary": "이미 사용 중인 닉네임", - "description": "NotAvailable", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "available": false + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -1575,19 +1668,26 @@ } } }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + "200": { + "description": "정상 조회. 본문의 `available` 필드로 사용 가능 여부 판단.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CheckNicknameResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "Available": { + "summary": "사용 가능한 닉네임", + "description": "Available", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "available": true + } + }, + "NotAvailable": { + "summary": "이미 사용 중인 닉네임", + "description": "NotAvailable", + "value": { + "available": false } } } @@ -1623,46 +1723,46 @@ "description": "인증된 사용자의 계정과 관련 데이터를 영구 삭제합니다. **이 작업은 되돌릴 수 없습니다.**\n\n### 삭제 대상\n- `members` 테이블의 해당 회원 row\n- `user_devices` 테이블의 해당 회원 row 전체 (FK CASCADE로 자동 삭제, 모든 디바이스 세션 무효화)\n- Firebase Authentication 의 해당 사용자 (uid = 회원의 socialId)\n\n### 처리 순서\n1. 회원 row 삭제 (`@Transactional`) → FK CASCADE로 user_devices 자동 삭제\n2. Firebase Authentication 사용자 삭제\n\n### 멱등성\n- 동일한 토큰으로 두 번 호출되거나, 다른 디바이스에서 먼저 탈퇴되어 회원이 이미 없는 상태에서 호출되어도 동일하게 **204**를 응답합니다.\n- Firebase 측에서 사용자가 이미 없는 경우(`USER_NOT_FOUND`)도 무시하고 정상 완료 처리합니다.\n- Firebase 일시 장애 등 외부 시스템 오류도 서버에서 로그만 남기고 클라이언트에는 **204**를 응답합니다 (우리 측 데이터 정리는 이미 완료).\n\n### 클라이언트 처리 가이드\n- 응답 받은 후 로컬에 저장된 Access Token / Refresh Token / 회원 정보를 모두 삭제하고 로그인 화면으로 이동하세요.\n- 네트워크 오류로 응답을 못 받은 경우 재시도 가능합니다 (멱등 보장).\n", "operationId": "withdraw", "responses": { - "204": { - "description": "탈퇴 성공. 응답 본문 없음. (이미 탈퇴된 상태 / Firebase 측 사용자 부재 / 외부 시스템 일시 오류 등 모두 포함)" - }, - "500": { - "description": "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InternalServerError": { - "description": "InternalServerError", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } } } }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + "500": { + "description": "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "InternalServerError": { + "description": "InternalServerError", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } } } + }, + "204": { + "description": "탈퇴 성공. 응답 본문 없음. (이미 탈퇴된 상태 / Firebase 측 사용자 부재 / 외부 시스템 일시 오류 등 모두 포함)" } } } @@ -1714,17 +1814,6 @@ }, "required": ["title"] }, - "ErrorResponse": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - } - } - }, "TodoResponse": { "type": "object", "description": "할 일 응답", @@ -1779,6 +1868,25 @@ } } }, + "ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "requiredFuel": { + "type": "integer", + "format": "int32" + }, + "currentFuel": { + "type": "integer", + "format": "int32" + } + } + }, "CategoryCreateRequest": { "type": "object", "description": "카테고리 생성 요청", @@ -1936,6 +2044,65 @@ } } }, + "RegionUnlockResponse": { + "type": "object", + "description": "지역 해금 응답", + "properties": { + "region": { + "$ref": "#/components/schemas/UnlockedNodeDto" + }, + "fuelConsumed": { + "type": "integer", + "format": "int32" + }, + "currentFuel": { + "type": "integer", + "format": "int32" + }, + "planetCleared": { + "type": "boolean" + } + } + }, + "UnlockedNodeDto": { + "type": "object", + "description": "해금된 노드 요약", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "isUnlocked": { + "type": "boolean" + }, + "isCleared": { + "type": "boolean" + }, + "unlockedAt": { + "type": "string", + "example": "2026-04-16T11:00:00Z" + } + } + }, + "PlanetUnlockResponse": { + "type": "object", + "description": "행성 해금 응답", + "properties": { + "planet": { + "$ref": "#/components/schemas/UnlockedNodeDto" + }, + "fuelConsumed": { + "type": "integer", + "format": "int32" + }, + "currentFuel": { + "type": "integer", + "format": "int32" + } + } + }, "ReissueRequest": { "type": "object", "description": "토큰 재발급 요청 본문", @@ -2329,6 +2496,141 @@ } } }, + "PlanetResponse": { + "type": "object", + "description": "행성 응답", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nodeType": { + "type": "string" + }, + "depth": { + "type": "integer", + "format": "int32" + }, + "icon": { + "type": "string" + }, + "parentId": { + "type": "string" + }, + "prerequisiteId": { + "type": "string" + }, + "requiredFuel": { + "type": "integer", + "format": "int32" + }, + "isUnlocked": { + "type": "boolean" + }, + "isCleared": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "description": { + "type": "string" + }, + "mapX": { + "type": "number", + "format": "double" + }, + "mapY": { + "type": "number", + "format": "double" + }, + "unlockedAt": { + "type": "string", + "example": "2026-04-01T00:00:00Z" + }, + "progress": { + "$ref": "#/components/schemas/ProgressDto" + } + } + }, + "ProgressDto": { + "type": "object", + "description": "행성 진행도", + "properties": { + "clearedChildren": { + "type": "integer", + "format": "int32", + "example": 3 + }, + "totalChildren": { + "type": "integer", + "format": "int32", + "example": 5 + }, + "progressRatio": { + "type": "number", + "format": "double", + "example": 0.6 + } + } + }, + "RegionResponse": { + "type": "object", + "description": "지역 응답", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nodeType": { + "type": "string" + }, + "depth": { + "type": "integer", + "format": "int32" + }, + "icon": { + "type": "string" + }, + "parentId": { + "type": "string" + }, + "requiredFuel": { + "type": "integer", + "format": "int32" + }, + "isUnlocked": { + "type": "boolean" + }, + "isCleared": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "description": { + "type": "string" + }, + "mapX": { + "type": "number", + "format": "double" + }, + "mapY": { + "type": "number", + "format": "double" + }, + "unlockedAt": { + "type": "string", + "example": "2026-04-05T15:30:00Z" + } + } + }, "CheckNicknameResponse": { "type": "object", "properties": { From 5a9d530cd958f9f3a0ede06be4eb5d9d8d837f58 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 18:53:16 +0900 Subject: [PATCH 02/14] =?UTF-8?q?docs=20:=20=ED=96=89=EC=84=B1/=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=ED=95=B4=EA=B8=88=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=8A=A4=ED=8E=99=C2=B7=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-05-29-exploration-backend-integration.md | 1895 +++++++++++++++++ ...-exploration-backend-integration-design.md | 267 +++ 2 files changed, 2162 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-exploration-backend-integration.md create mode 100644 docs/superpowers/specs/2026-05-29-exploration-backend-integration-design.md diff --git a/docs/superpowers/plans/2026-05-29-exploration-backend-integration.md b/docs/superpowers/plans/2026-05-29-exploration-backend-integration.md new file mode 100644 index 0000000..c41c646 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-exploration-backend-integration.md @@ -0,0 +1,1895 @@ +# 행성/지역 해금(Exploration) 백엔드 연동 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 인증(소셜 로그인) 사용자의 행성/지역 해금을 백엔드 API에 연동하고, 게스트는 기존 로컬 경로를 그대로 유지한다. + +**Architecture:** fuel feature 패턴 복제 — `explorationRepositoryProvider`가 `isAuthenticatedProvider`로 분기(인증→Remote, 게스트→Local). 서버가 카탈로그·상태의 source of truth. 동기 `ExplorationNotifier`를 AsyncNotifier로 전환하고 소비 화면 4곳을 `AsyncValue` 언랩으로 조정. unlock은 서버가 원자적으로 연료 차감. + +**Tech Stack:** Flutter · Riverpod 2.6 (generator) · Freezed + JsonSerializable · Dio + Retrofit · mocktail · flutter_test + +--- + +## 파일 구조 + +**신규 (Data Layer):** +- `lib/features/exploration/data/models/planet_response_model.dart` — `PlanetResponse` DTO + 중첩 `ProgressResponseModel` +- `lib/features/exploration/data/models/region_response_model.dart` — `RegionResponse` DTO +- `lib/features/exploration/data/models/unlock_response_models.dart` — `RegionUnlockResponseModel`/`PlanetUnlockResponseModel` + 중첩 `UnlockedNodeModel` +- `lib/features/exploration/data/datasources/exploration_remote_datasource.dart` — Retrofit 4 엔드포인트 +- `lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart` — 원격 구현 + 에러 매핑 + progress 캐시 + +**신규 (Domain Layer):** +- `lib/features/exploration/domain/entities/unlock_result_entities.dart` — `RegionUnlockResultEntity`/`PlanetUnlockResultEntity` (plain class) +- `lib/features/exploration/domain/exceptions/exploration_exceptions.dart` — plain Exception 4종 + +**변경:** +- `lib/core/constants/api_endpoints.dart` — Exploration 경로 추가 +- `lib/features/exploration/domain/repositories/exploration_repository.dart` — 전 메서드 async + 결과 엔티티 반환 +- `lib/features/exploration/data/repositories/exploration_repository_impl.dart` — async 래핑(로컬) +- `lib/features/exploration/presentation/providers/exploration_provider.dart` — 원격 DS provider + repo 분기 + AsyncNotifier +- `lib/features/auth/presentation/providers/auth_provider.dart` — `_clearGuestData` 한 줄 +- `lib/features/explore/presentation/screens/explore_screen.dart` +- `lib/features/exploration/presentation/screens/exploration_detail_screen.dart` +- `lib/features/exploration/presentation/screens/location_detail_screen.dart` +- `lib/features/exploration/presentation/widgets/planet_node.dart` + +**중요 — 빌드 그린 경계:** Task 1~7(가산적 신규 코드)은 각각 독립 컴파일·커밋 가능. Task 8~15(인터페이스 async 전환 → 로컬 repo → provider → 화면 4곳)는 **함께 컴파일되어야** 하므로 하나의 묶음으로 진행하고 Task 15 끝에서 build_runner+analyze+test 통과 후 커밋한다. + +--- + +## Phase 1 — Domain & 상수 (가산적, 독립 커밋) + +### Task 1: ApiEndpoints에 Exploration 경로 추가 + +**Files:** +- Modify: `lib/core/constants/api_endpoints.dart` + +- [ ] **Step 1: 상수 추가** + +`api_endpoints.dart`의 Timer 섹션 끝(`timerSessionsTodayStats` 줄 다음, 클래스 닫는 `}` 직전)에 추가: + +```dart + + // ============================================ + // Exploration + // ============================================ + + /// 전체 행성 목록 + static const explorationPlanets = '/api/explorations/planets'; + + /// 특정 행성 하위 지역 목록 + static String explorationRegions(String planetId) => + '/api/explorations/planets/$planetId/regions'; + + /// 지역 해금 (연료 소비) + static String explorationUnlockRegion(String regionId) => + '/api/explorations/regions/$regionId/unlock'; + + /// 행성 해금 (연료 소비) + static String explorationUnlockPlanet(String planetId) => + '/api/explorations/planets/$planetId/unlock'; +``` + +- [ ] **Step 2: analyze 확인** + +Run: `flutter analyze lib/core/constants/api_endpoints.dart` +Expected: No issues found. + +- [ ] **Step 3: Commit** + +```bash +git add lib/core/constants/api_endpoints.dart +git commit -m "feat : Exploration API 엔드포인트 상수 추가" +``` + +--- + +### Task 2: 결과 엔티티 + 예외 정의 + +**Files:** +- Create: `lib/features/exploration/domain/entities/unlock_result_entities.dart` +- Create: `lib/features/exploration/domain/exceptions/exploration_exceptions.dart` +- Test: `test/features/exploration/domain/unlock_result_entities_test.dart` + +- [ ] **Step 1: 실패 테스트 작성** + +`test/features/exploration/domain/unlock_result_entities_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/domain/entities/unlock_result_entities.dart'; + +void main() { + test('RegionUnlockResultEntity — 필드 보관', () { + const r = RegionUnlockResultEntity(planetCleared: true, currentFuel: 42); + expect(r.planetCleared, true); + expect(r.currentFuel, 42); + }); + + test('PlanetUnlockResultEntity — 필드 보관', () { + const p = PlanetUnlockResultEntity(currentFuel: 7); + expect(p.currentFuel, 7); + }); + + test('게스트 센티넬 currentFuel = -1 허용', () { + const r = RegionUnlockResultEntity(planetCleared: false, currentFuel: -1); + expect(r.currentFuel, -1); + }); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `flutter test test/features/exploration/domain/unlock_result_entities_test.dart` +Expected: FAIL — `Target of URI doesn't exist: 'unlock_result_entities.dart'` + +- [ ] **Step 3: 엔티티 구현** + +`lib/features/exploration/domain/entities/unlock_result_entities.dart`: + +```dart +/// 지역 해금 결과 +/// +/// 게스트는 연료를 Notifier가 별도 차감하므로 [currentFuel]은 `-1`(미사용 센티넬). +/// 인증(서버) 경로는 서버가 차감 후 반환한 잔량을 담는다. +class RegionUnlockResultEntity { + const RegionUnlockResultEntity({ + required this.planetCleared, + required this.currentFuel, + }); + + /// 이 지역 해금으로 상위 행성이 모두 클리어되었는지 + final bool planetCleared; + + /// 해금 후 잔여 연료 (게스트: -1 센티넬) + final int currentFuel; +} + +/// 행성 해금 결과 +class PlanetUnlockResultEntity { + const PlanetUnlockResultEntity({required this.currentFuel}); + + /// 해금 후 잔여 연료 (게스트: -1 센티넬) + final int currentFuel; +} +``` + +- [ ] **Step 4: 예외 구현** + +`lib/features/exploration/domain/exceptions/exploration_exceptions.dart`: + +```dart +/// 탐험 해금 도메인 예외. +/// +/// 연료 부족(`INSUFFICIENT_FUEL`)은 별도 클래스를 만들지 않고 fuel 도메인의 +/// 기존 `InsufficientFuelException`을 재사용한다 (showUnlockDialog가 이미 catch). +/// 아래 예외들은 화면 사전검증을 통과한 레이스 상황의 안전망이다. + +/// 이미 해금된 노드 (`ALREADY_UNLOCKED`) +class NodeAlreadyUnlockedException implements Exception { + const NodeAlreadyUnlockedException([this.message = '이미 해금된 곳입니다.']); + final String message; + @override + String toString() => 'NodeAlreadyUnlockedException: $message'; +} + +/// 상위 행성이 잠겨 지역을 해금할 수 없음 (`PLANET_LOCKED`) +class PlanetLockedException implements Exception { + const PlanetLockedException([this.message = '먼저 행성을 해금해야 합니다.']); + final String message; + @override + String toString() => 'PlanetLockedException: $message'; +} + +/// 선행 행성 미클리어로 해금 불가 (`PREREQUISITE_NOT_CLEARED`) +class PrerequisiteNotClearedException implements Exception { + const PrerequisiteNotClearedException([ + this.message = '이전 행성을 먼저 클리어해야 합니다.', + ]); + final String message; + @override + String toString() => 'PrerequisiteNotClearedException: $message'; +} + +/// 노드 미존재 (`REGION_NOT_FOUND` / `PLANET_NOT_FOUND`) +class ExplorationNodeNotFoundException implements Exception { + const ExplorationNodeNotFoundException([ + this.message = '해당 탐험 지점을 찾을 수 없습니다.', + ]); + final String message; + @override + String toString() => 'ExplorationNodeNotFoundException: $message'; +} +``` + +- [ ] **Step 5: 테스트 통과 확인** + +Run: `flutter test test/features/exploration/domain/unlock_result_entities_test.dart` +Expected: PASS (3 tests) + +- [ ] **Step 6: Commit** + +```bash +git add lib/features/exploration/domain/entities/unlock_result_entities.dart lib/features/exploration/domain/exceptions/exploration_exceptions.dart test/features/exploration/domain/unlock_result_entities_test.dart +git commit -m "feat : 탐험 해금 결과 엔티티 및 도메인 예외 추가" +``` + +--- + +## Phase 2 — Data 모델 (Freezed DTO, 가산적) + +### Task 3: PlanetResponseModel + ProgressResponseModel + +**Files:** +- Create: `lib/features/exploration/data/models/planet_response_model.dart` +- Test: `test/features/exploration/data/models/planet_response_model_test.dart` + +- [ ] **Step 1: 모델 작성 (codegen 대상)** + +`lib/features/exploration/data/models/planet_response_model.dart`: + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/exploration_node_entity.dart'; +import '../../domain/entities/exploration_progress_entity.dart'; + +part 'planet_response_model.freezed.dart'; +part 'planet_response_model.g.dart'; + +/// GET /api/explorations/planets 응답 항목 DTO +@freezed +class PlanetResponseModel with _$PlanetResponseModel { + const PlanetResponseModel._(); + + const factory PlanetResponseModel({ + required String id, + required String name, + required String nodeType, + required int depth, + required String icon, + String? parentId, + String? prerequisiteId, + required int requiredFuel, + required bool isUnlocked, + required bool isCleared, + required int sortOrder, + @Default('') String description, + @Default(0.5) double mapX, + @Default(0.0) double mapY, + DateTime? unlockedAt, + ProgressResponseModel? progress, + }) = _PlanetResponseModel; + + factory PlanetResponseModel.fromJson(Map json) => + _$PlanetResponseModelFromJson(json); + + ExplorationNodeEntity toEntity() => ExplorationNodeEntity( + id: id, + name: name, + nodeType: nodeTypeFromString(nodeType), + depth: depth, + icon: icon, + parentId: parentId, + requiredFuel: requiredFuel, + isUnlocked: isUnlocked, + isCleared: isCleared, + sortOrder: sortOrder, + description: description, + mapX: mapX, + mapY: mapY, + unlockedAt: unlockedAt, + ); + + ExplorationProgressEntity progressEntity() => ExplorationProgressEntity( + nodeId: id, + clearedChildren: progress?.clearedChildren ?? 0, + totalChildren: progress?.totalChildren ?? 0, + ); +} + +/// 행성 진행도 중첩 DTO +@freezed +class ProgressResponseModel with _$ProgressResponseModel { + const factory ProgressResponseModel({ + required int clearedChildren, + required int totalChildren, + @Default(0.0) double progressRatio, + }) = _ProgressResponseModel; + + factory ProgressResponseModel.fromJson(Map json) => + _$ProgressResponseModelFromJson(json); +} + +/// 서버 nodeType 문자열 → enum 매핑 (재사용) +ExplorationNodeType nodeTypeFromString(String raw) { + switch (raw) { + case 'galaxy': + return ExplorationNodeType.galaxy; + case 'starSystem': + case 'star_system': + return ExplorationNodeType.starSystem; + case 'planet': + return ExplorationNodeType.planet; + case 'region': + return ExplorationNodeType.region; + default: + return ExplorationNodeType.region; + } +} +``` + +- [ ] **Step 2: 코드 생성** + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: `planet_response_model.freezed.dart`, `planet_response_model.g.dart` 생성, 빌드 성공. + +- [ ] **Step 3: 테스트 작성** + +`test/features/exploration/data/models/planet_response_model_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/data/models/planet_response_model.dart'; +import 'package:space_study_ship/features/exploration/domain/entities/exploration_node_entity.dart'; + +void main() { + group('PlanetResponseModel', () { + test('fromJson + toEntity — 해금된 행성 (progress 포함)', () { + final json = { + 'id': 'mars', + 'name': '화성', + 'nodeType': 'planet', + 'depth': 2, + 'icon': 'mars', + 'parentId': null, + 'prerequisiteId': 'venus', + 'requiredFuel': 10, + 'isUnlocked': true, + 'isCleared': false, + 'sortOrder': 3, + 'description': '붉은 행성', + 'mapX': 0.25, + 'mapY': 0.44, + 'unlockedAt': '2026-04-01T00:00:00Z', + 'progress': { + 'clearedChildren': 3, + 'totalChildren': 5, + 'progressRatio': 0.6, + }, + }; + final model = PlanetResponseModel.fromJson(json); + final entity = model.toEntity(); + + expect(entity.id, 'mars'); + expect(entity.nodeType, ExplorationNodeType.planet); + expect(entity.requiredFuel, 10); + expect(entity.isUnlocked, true); + expect(entity.unlockedAt, DateTime.utc(2026, 4, 1)); + + final progress = model.progressEntity(); + expect(progress.clearedChildren, 3); + expect(progress.totalChildren, 5); + expect(progress.progressRatio, closeTo(0.6, 0.001)); + }); + + test('fromJson — 잠긴 행성 (unlockedAt/progress 없음)', () { + final json = { + 'id': 'neptune', + 'name': '해왕성', + 'nodeType': 'planet', + 'depth': 2, + 'icon': 'neptune', + 'requiredFuel': 60, + 'isUnlocked': false, + 'isCleared': false, + 'sortOrder': 7, + }; + final model = PlanetResponseModel.fromJson(json); + final entity = model.toEntity(); + + expect(entity.unlockedAt, isNull); + expect(entity.isUnlocked, false); + expect(model.progressEntity().totalChildren, 0); + }); + }); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `flutter test test/features/exploration/data/models/planet_response_model_test.dart` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/exploration/data/models/planet_response_model.dart lib/features/exploration/data/models/planet_response_model.freezed.dart lib/features/exploration/data/models/planet_response_model.g.dart test/features/exploration/data/models/planet_response_model_test.dart +git commit -m "feat : PlanetResponseModel DTO 및 enum 매핑 추가" +``` + +--- + +### Task 4: RegionResponseModel + +**Files:** +- Create: `lib/features/exploration/data/models/region_response_model.dart` +- Test: `test/features/exploration/data/models/region_response_model_test.dart` + +- [ ] **Step 1: 모델 작성** + +`lib/features/exploration/data/models/region_response_model.dart`: + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/exploration_node_entity.dart'; +import 'planet_response_model.dart' show nodeTypeFromString; + +part 'region_response_model.freezed.dart'; +part 'region_response_model.g.dart'; + +/// GET /api/explorations/planets/{planetId}/regions 응답 항목 DTO +@freezed +class RegionResponseModel with _$RegionResponseModel { + const RegionResponseModel._(); + + const factory RegionResponseModel({ + required String id, + required String name, + required String nodeType, + required int depth, + required String icon, + String? parentId, + required int requiredFuel, + required bool isUnlocked, + required bool isCleared, + required int sortOrder, + @Default('') String description, + @Default(0.5) double mapX, + @Default(0.0) double mapY, + DateTime? unlockedAt, + }) = _RegionResponseModel; + + factory RegionResponseModel.fromJson(Map json) => + _$RegionResponseModelFromJson(json); + + ExplorationNodeEntity toEntity() => ExplorationNodeEntity( + id: id, + name: name, + nodeType: nodeTypeFromString(nodeType), + depth: depth, + icon: icon, + parentId: parentId, + requiredFuel: requiredFuel, + isUnlocked: isUnlocked, + isCleared: isCleared, + sortOrder: sortOrder, + description: description, + mapX: mapX, + mapY: mapY, + unlockedAt: unlockedAt, + ); +} +``` + +- [ ] **Step 2: 코드 생성** + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: 빌드 성공, `region_response_model.*` 생성. + +- [ ] **Step 3: 테스트 작성** + +`test/features/exploration/data/models/region_response_model_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/data/models/region_response_model.dart'; +import 'package:space_study_ship/features/exploration/domain/entities/exploration_node_entity.dart'; + +void main() { + test('RegionResponseModel — fromJson + toEntity', () { + final json = { + 'id': 'japan', + 'name': '일본', + 'nodeType': 'region', + 'depth': 3, + 'icon': 'JP', + 'parentId': 'earth', + 'requiredFuel': 5, + 'isUnlocked': true, + 'isCleared': true, + 'sortOrder': 1, + 'description': '이웃 섬나라', + 'unlockedAt': '2026-04-05T15:30:00Z', + }; + final entity = RegionResponseModel.fromJson(json).toEntity(); + expect(entity.id, 'japan'); + expect(entity.nodeType, ExplorationNodeType.region); + expect(entity.parentId, 'earth'); + expect(entity.isCleared, true); + expect(entity.unlockedAt, DateTime.utc(2026, 4, 5, 15, 30)); + }); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `flutter test test/features/exploration/data/models/region_response_model_test.dart` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/exploration/data/models/region_response_model.dart lib/features/exploration/data/models/region_response_model.freezed.dart lib/features/exploration/data/models/region_response_model.g.dart test/features/exploration/data/models/region_response_model_test.dart +git commit -m "feat : RegionResponseModel DTO 추가" +``` + +--- + +### Task 5: Unlock 응답 모델 (region + planet + UnlockedNode) + +**Files:** +- Create: `lib/features/exploration/data/models/unlock_response_models.dart` +- Test: `test/features/exploration/data/models/unlock_response_models_test.dart` + +- [ ] **Step 1: 모델 작성** + +`lib/features/exploration/data/models/unlock_response_models.dart`: + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/unlock_result_entities.dart'; + +part 'unlock_response_models.freezed.dart'; +part 'unlock_response_models.g.dart'; + +/// 해금된 노드 요약 (중첩) +@freezed +class UnlockedNodeModel with _$UnlockedNodeModel { + const factory UnlockedNodeModel({ + required String id, + required String name, + required bool isUnlocked, + required bool isCleared, + DateTime? unlockedAt, + }) = _UnlockedNodeModel; + + factory UnlockedNodeModel.fromJson(Map json) => + _$UnlockedNodeModelFromJson(json); +} + +/// POST /api/explorations/regions/{regionId}/unlock 응답 +@freezed +class RegionUnlockResponseModel with _$RegionUnlockResponseModel { + const RegionUnlockResponseModel._(); + + const factory RegionUnlockResponseModel({ + UnlockedNodeModel? region, + required int fuelConsumed, + required int currentFuel, + required bool planetCleared, + }) = _RegionUnlockResponseModel; + + factory RegionUnlockResponseModel.fromJson(Map json) => + _$RegionUnlockResponseModelFromJson(json); + + RegionUnlockResultEntity toEntity() => RegionUnlockResultEntity( + planetCleared: planetCleared, + currentFuel: currentFuel, + ); +} + +/// POST /api/explorations/planets/{planetId}/unlock 응답 +@freezed +class PlanetUnlockResponseModel with _$PlanetUnlockResponseModel { + const PlanetUnlockResponseModel._(); + + const factory PlanetUnlockResponseModel({ + UnlockedNodeModel? planet, + required int fuelConsumed, + required int currentFuel, + }) = _PlanetUnlockResponseModel; + + factory PlanetUnlockResponseModel.fromJson(Map json) => + _$PlanetUnlockResponseModelFromJson(json); + + PlanetUnlockResultEntity toEntity() => + PlanetUnlockResultEntity(currentFuel: currentFuel); +} +``` + +- [ ] **Step 2: 코드 생성** + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: 빌드 성공. + +- [ ] **Step 3: 테스트 작성** + +`test/features/exploration/data/models/unlock_response_models_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/data/models/unlock_response_models.dart'; + +void main() { + test('RegionUnlockResponseModel — toEntity', () { + final json = { + 'region': { + 'id': 'japan', + 'name': '일본', + 'isUnlocked': true, + 'isCleared': true, + 'unlockedAt': '2026-04-16T11:00:00Z', + }, + 'fuelConsumed': 5, + 'currentFuel': 95, + 'planetCleared': false, + }; + final entity = RegionUnlockResponseModel.fromJson(json).toEntity(); + expect(entity.planetCleared, false); + expect(entity.currentFuel, 95); + }); + + test('PlanetUnlockResponseModel — toEntity', () { + final json = { + 'planet': { + 'id': 'mars', + 'name': '화성', + 'isUnlocked': true, + 'isCleared': false, + }, + 'fuelConsumed': 10, + 'currentFuel': 40, + }; + final entity = PlanetUnlockResponseModel.fromJson(json).toEntity(); + expect(entity.currentFuel, 40); + }); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `flutter test test/features/exploration/data/models/unlock_response_models_test.dart` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/exploration/data/models/unlock_response_models.dart lib/features/exploration/data/models/unlock_response_models.freezed.dart lib/features/exploration/data/models/unlock_response_models.g.dart test/features/exploration/data/models/unlock_response_models_test.dart +git commit -m "feat : 행성/지역 해금 응답 모델 추가" +``` + +--- + +## Phase 3 — Remote DataSource & Repository (가산적) + +### Task 6: ExplorationRemoteDataSource (Retrofit) + +**Files:** +- Create: `lib/features/exploration/data/datasources/exploration_remote_datasource.dart` + +- [ ] **Step 1: 데이터소스 작성** + +`lib/features/exploration/data/datasources/exploration_remote_datasource.dart`: + +```dart +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/constants/api_endpoints.dart'; +import '../models/planet_response_model.dart'; +import '../models/region_response_model.dart'; +import '../models/unlock_response_models.dart'; + +part 'exploration_remote_datasource.g.dart'; + +/// Exploration 백엔드 API 클라이언트 (api-docs.json Exploration 태그 4 엔드포인트). +@RestApi() +abstract class ExplorationRemoteDataSource { + factory ExplorationRemoteDataSource(Dio dio) = _ExplorationRemoteDataSource; + + /// 전체 행성 목록 — 200 + @GET(ApiEndpoints.explorationPlanets) + Future> getPlanets(); + + /// 행성 하위 지역 목록 — 200 / 404 PLANET_NOT_FOUND + @GET('/api/explorations/planets/{planetId}/regions') + Future> getRegions(@Path('planetId') String planetId); + + /// 지역 해금 — 200 / 400 / 404 + @POST('/api/explorations/regions/{regionId}/unlock') + Future unlockRegion(@Path('regionId') String regionId); + + /// 행성 해금 — 200 / 400 / 404 + @POST('/api/explorations/planets/{planetId}/unlock') + Future unlockPlanet(@Path('planetId') String planetId); +} +``` + +> Retrofit `@Path` 사용 메서드는 경로 리터럴이 필요하므로 `ApiEndpoints`의 함수형 상수 대신 경로 문자열을 직접 사용한다(엔드포인트 함수는 호출부 가독성용으로 유지). + +- [ ] **Step 2: 코드 생성** + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: `exploration_remote_datasource.g.dart` 생성, 빌드 성공. + +- [ ] **Step 3: analyze 확인** + +Run: `flutter analyze lib/features/exploration/data/datasources/exploration_remote_datasource.dart` +Expected: No issues found. + +- [ ] **Step 4: Commit** + +```bash +git add lib/features/exploration/data/datasources/exploration_remote_datasource.dart lib/features/exploration/data/datasources/exploration_remote_datasource.g.dart +git commit -m "feat : ExplorationRemoteDataSource 추가" +``` + +--- + +### Task 7: ExplorationRemoteRepositoryImpl (에러 매핑 + progress 캐시) + +> 이 Repository는 **아직 인터페이스가 sync**이므로, 지금은 인터페이스를 implements 하지 않고 독립 클래스로 작성·테스트한다. Task 8에서 인터페이스가 async로 바뀌면 `implements ExplorationRepository`를 붙인다. (이렇게 해야 Phase 3까지 빌드 그린 유지) + +**Files:** +- Create: `lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart` +- Test: `test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart` + +- [ ] **Step 1: 실패 테스트 작성** + +`test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart`: + +```dart +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:space_study_ship/features/exploration/data/datasources/exploration_remote_datasource.dart'; +import 'package:space_study_ship/features/exploration/data/models/planet_response_model.dart'; +import 'package:space_study_ship/features/exploration/data/models/unlock_response_models.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_remote_repository_impl.dart'; +import 'package:space_study_ship/features/exploration/domain/exceptions/exploration_exceptions.dart'; +import 'package:space_study_ship/features/fuel/domain/exceptions/fuel_exceptions.dart'; + +class _MockRemote extends Mock implements ExplorationRemoteDataSource {} + +void main() { + late _MockRemote remote; + late ExplorationRemoteRepositoryImpl repo; + + setUp(() { + remote = _MockRemote(); + repo = ExplorationRemoteRepositoryImpl(remote); + }); + + PlanetResponseModel planet(String id) => PlanetResponseModel( + id: id, + name: id, + nodeType: 'planet', + depth: 2, + icon: id, + requiredFuel: 10, + isUnlocked: false, + isCleared: false, + sortOrder: 0, + progress: const ProgressResponseModel(clearedChildren: 1, totalChildren: 4), + ); + + DioException unlockError(String code, {int? requiredFuel, int? currentFuel}) => + DioException( + requestOptions: RequestOptions(path: '/api/explorations/regions/x/unlock'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 400, + data: { + 'code': code, + 'message': 'msg', + if (requiredFuel != null) 'requiredFuel': requiredFuel, + if (currentFuel != null) 'currentFuel': currentFuel, + }, + ), + ); + + test('getPlanets — 매핑 + getProgress 캐시 사용', () async { + when(() => remote.getPlanets()).thenAnswer((_) async => [planet('mars')]); + + final planets = await repo.getPlanets(); + expect(planets.single.id, 'mars'); + + // getProgress는 추가 fetch 없이 캐시에서 반환 + final progress = await repo.getProgress('mars'); + expect(progress.clearedChildren, 1); + expect(progress.totalChildren, 4); + verify(() => remote.getPlanets()).called(1); // 단 1회 + }); + + test('unlockRegion INSUFFICIENT_FUEL → fuel InsufficientFuelException', () async { + when(() => remote.unlockRegion('seoul')).thenThrow( + unlockError('INSUFFICIENT_FUEL', requiredFuel: 10, currentFuel: 3), + ); + + expect( + () => repo.unlockRegion('seoul'), + throwsA( + isA() + .having((e) => e.requiredAmount, 'requiredAmount', 10) + .having((e) => e.available, 'available', 3), + ), + ); + }); + + test('unlockRegion ALREADY_UNLOCKED → NodeAlreadyUnlockedException', () async { + when(() => remote.unlockRegion('seoul')) + .thenThrow(unlockError('ALREADY_UNLOCKED')); + expect( + () => repo.unlockRegion('seoul'), + throwsA(isA()), + ); + }); + + test('unlockPlanet 성공 → 결과 엔티티', () async { + when(() => remote.unlockPlanet('mars')).thenAnswer( + (_) async => const PlanetUnlockResponseModel(fuelConsumed: 10, currentFuel: 40), + ); + final result = await repo.unlockPlanet('mars'); + expect(result.currentFuel, 40); + }); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `flutter test test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart` +Expected: FAIL — `exploration_remote_repository_impl.dart` 없음. + +- [ ] **Step 3: Repository 구현** + +`lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart`: + +```dart +import 'package:dio/dio.dart'; + +import '../../../../core/network/dio_exception_handler.dart'; +import '../../../fuel/domain/exceptions/fuel_exceptions.dart'; +import '../../domain/entities/exploration_node_entity.dart'; +import '../../domain/entities/exploration_progress_entity.dart'; +import '../../domain/entities/unlock_result_entities.dart'; +import '../../domain/exceptions/exploration_exceptions.dart'; +import '../datasources/exploration_remote_datasource.dart'; + +/// 서버 source-of-truth 탐험 Repository (인증 사용자). +/// +/// 행성 목록을 받을 때 progress를 in-memory 캐시에 저장해, getProgress가 +/// 추가 네트워크 호출 없이 반환하도록 한다. +class ExplorationRemoteRepositoryImpl { + final ExplorationRemoteDataSource _remote; + + ExplorationRemoteRepositoryImpl(this._remote); + + List _planetsCache = const []; + final Map _progressCache = {}; + + Future> getPlanets() async { + try { + final models = await _remote.getPlanets(); + _planetsCache = models.map((m) => m.toEntity()).toList(growable: false); + _progressCache + ..clear() + ..addEntries(models.map((m) => MapEntry(m.id, m.progressEntity()))); + return _planetsCache; + } on DioException catch (e) { + throw DioExceptionHandler.handle(e); + } + } + + Future getPlanet(String planetId) async { + final cached = _findById(_planetsCache, planetId); + if (cached != null) return cached; + final found = _findById(await getPlanets(), planetId); + if (found == null) throw const ExplorationNodeNotFoundException(); + return found; + } + + ExplorationNodeEntity? _findById( + List list, + String id, + ) { + for (final node in list) { + if (node.id == id) return node; + } + return null; + } + + Future> getRegions(String planetId) async { + try { + final models = await _remote.getRegions(planetId); + return models.map((m) => m.toEntity()).toList(growable: false); + } on DioException catch (e) { + throw _mapUnlockError(e); + } + } + + Future unlockRegion(String regionId) async { + try { + final res = await _remote.unlockRegion(regionId); + return res.toEntity(); + } on DioException catch (e) { + throw _mapUnlockError(e); + } + } + + Future unlockPlanet(String planetId) async { + try { + final res = await _remote.unlockPlanet(planetId); + return res.toEntity(); + } on DioException catch (e) { + throw _mapUnlockError(e); + } + } + + Future getProgress(String planetId) async { + final cached = _progressCache[planetId]; + if (cached != null) return cached; + await getPlanets(); + return _progressCache[planetId] ?? + ExplorationProgressEntity( + nodeId: planetId, + clearedChildren: 0, + totalChildren: 0, + ); + } + + /// 서버 데이터는 탈퇴/로그아웃 API가 정리. 캐시만 비운다 (호출 경로 없음). + Future clearAll() async { + _planetsCache = const []; + _progressCache.clear(); + } + + /// unlock/regions 에러 본문 code → 도메인 예외 매핑. + Object _mapUnlockError(DioException e) { + final data = e.response?.data; + if (data is Map) { + switch (data['code']) { + case 'INSUFFICIENT_FUEL': + return InsufficientFuelException( + requiredAmount: (data['requiredFuel'] as num?)?.toInt() ?? 0, + available: (data['currentFuel'] as num?)?.toInt() ?? 0, + ); + case 'ALREADY_UNLOCKED': + return const NodeAlreadyUnlockedException(); + case 'PLANET_LOCKED': + return const PlanetLockedException(); + case 'PREREQUISITE_NOT_CLEARED': + return const PrerequisiteNotClearedException(); + case 'REGION_NOT_FOUND': + case 'PLANET_NOT_FOUND': + return const ExplorationNodeNotFoundException(); + } + } + return DioExceptionHandler.handle(e); + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `flutter test test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart +git commit -m "feat : ExplorationRemoteRepositoryImpl 및 에러 매핑 추가" +``` + +--- + +## Phase 4 — async 전환 묶음 (Task 8~15 함께 컴파일, Task 15 끝에 커밋) + +> **주의:** Task 8에서 인터페이스가 async로 바뀌는 순간 provider·화면이 깨진다. Task 8~15는 중간 커밋 없이 진행하고, Task 15의 build_runner+analyze+test 통과 후 한 번에 커밋한다. 진행 중에는 `flutter analyze`로 남은 컴파일 오류를 추적한다. + +### Task 8: Repository 인터페이스 async 전환 + 로컬 구현 + remote implements + +**Files:** +- Modify: `lib/features/exploration/domain/repositories/exploration_repository.dart` +- Modify: `lib/features/exploration/data/repositories/exploration_repository_impl.dart` +- Modify: `lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart` +- Test: `test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart` + +- [ ] **Step 1: 인터페이스 교체** + +`lib/features/exploration/domain/repositories/exploration_repository.dart` 전체를 교체: + +```dart +import '../entities/exploration_node_entity.dart'; +import '../entities/exploration_progress_entity.dart'; +import '../entities/unlock_result_entities.dart'; + +/// 탐험 Repository 인터페이스 +/// +/// 게스트: [ExplorationLocalRepositoryImpl] (SharedPreferences) +/// 인증: [ExplorationRemoteRepositoryImpl] (Backend API) +abstract class ExplorationRepository { + Future> getPlanets(); + Future getPlanet(String planetId); + Future> getRegions(String planetId); + + /// 지역 해금 (해금 = 클리어). 게스트 구현의 [RegionUnlockResultEntity.currentFuel]은 -1. + Future unlockRegion(String regionId); + + /// 행성 해금. 게스트 구현의 [PlanetUnlockResultEntity.currentFuel]은 -1. + Future unlockPlanet(String planetId); + + Future getProgress(String planetId); + + /// 전체 삭제 (게스트 로그아웃 시) + Future clearAll(); +} +``` + +- [ ] **Step 2: 로컬 구현 async 전환** + +`lib/features/exploration/data/repositories/exploration_repository_impl.dart` 전체를 교체: + +```dart +import 'package:flutter/foundation.dart'; + +import '../../domain/entities/exploration_node_entity.dart'; +import '../../domain/entities/exploration_progress_entity.dart'; +import '../../domain/entities/unlock_result_entities.dart'; +import '../../domain/repositories/exploration_repository.dart'; +import '../datasources/exploration_local_datasource.dart'; +import '../seed/exploration_seed_data.dart'; + +/// 로컬 탐험 Repository 구현체 (게스트). +/// +/// 시드 데이터(정적) + SharedPreferences(상태)를 머지. 연료 차감은 Notifier가 +/// 별도 수행하므로 결과 엔티티의 currentFuel은 -1 센티넬. +class ExplorationLocalRepositoryImpl implements ExplorationRepository { + final ExplorationLocalDataSource _localDataSource; + + ExplorationLocalRepositoryImpl(this._localDataSource); + + @override + Future> getPlanets() async { + final states = _localDataSource.getAllStates(); + return ExplorationSeedData.planets.map((planet) { + final state = states[planet.id]; + if (state == null) return planet; + return planet.copyWith( + isUnlocked: state.isUnlocked || planet.isUnlocked, + isCleared: state.isCleared || planet.isCleared, + unlockedAt: state.unlockedAt, + ); + }).toList(); + } + + @override + Future getPlanet(String planetId) async { + final planet = ExplorationSeedData.getPlanet(planetId); + final states = _localDataSource.getAllStates(); + final state = states[planetId]; + if (state == null) return planet; + return planet.copyWith( + isUnlocked: state.isUnlocked || planet.isUnlocked, + isCleared: state.isCleared || planet.isCleared, + unlockedAt: state.unlockedAt, + ); + } + + @override + Future> getRegions(String planetId) async { + final states = _localDataSource.getAllStates(); + return ExplorationSeedData.getRegions(planetId).map((region) { + final state = states[region.id]; + if (state == null) return region; + return region.copyWith( + isUnlocked: state.isUnlocked || region.isUnlocked, + isCleared: state.isCleared || region.isCleared, + unlockedAt: state.unlockedAt, + ); + }).toList(); + } + + @override + Future unlockRegion(String regionId) async { + final now = DateTime.now(); + await _localDataSource.saveNodeState( + ExplorationNodeState( + nodeId: regionId, + isUnlocked: true, + isCleared: true, + unlockedAt: now, + ), + ); + final planetCleared = await _checkPlanetAutoComplete(regionId); + return RegionUnlockResultEntity( + planetCleared: planetCleared, + currentFuel: -1, + ); + } + + @override + Future unlockPlanet(String planetId) async { + await _localDataSource.saveNodeState( + ExplorationNodeState( + nodeId: planetId, + isUnlocked: true, + unlockedAt: DateTime.now(), + ), + ); + return const PlanetUnlockResultEntity(currentFuel: -1); + } + + @override + Future getProgress(String planetId) async { + final regions = await getRegions(planetId); + final cleared = regions.where((r) => r.isCleared).length; + return ExplorationProgressEntity( + nodeId: planetId, + clearedChildren: cleared, + totalChildren: regions.length, + ); + } + + @override + Future clearAll() async { + await _localDataSource.clearAll(); + } + + /// 부모 행성 자동 클리어 체크. 새로 클리어되면 true. + Future _checkPlanetAutoComplete(String regionId) async { + String? parentPlanetId; + for (final entry in ExplorationSeedData.regions.entries) { + if (entry.value.any((r) => r.id == regionId)) { + parentPlanetId = entry.key; + break; + } + } + if (parentPlanetId == null) return false; + + final regions = await getRegions(parentPlanetId); + final allCleared = regions.every((r) => r.isCleared); + if (allCleared) { + await _localDataSource.saveNodeState( + ExplorationNodeState( + nodeId: parentPlanetId, + isUnlocked: true, + isCleared: true, + unlockedAt: DateTime.now(), + ), + ); + debugPrint('행성 $parentPlanetId 자동 클리어'); + } + return allCleared; + } +} +``` + +- [ ] **Step 3: 원격 구현에 `implements` 부착** + +`exploration_remote_repository_impl.dart`에서 import에 인터페이스를 추가하고 클래스 선언과 메서드에 override를 단다. + +import 블록에 추가: +```dart +import '../../domain/repositories/exploration_repository.dart'; +``` +클래스 선언 변경: +```dart +class ExplorationRemoteRepositoryImpl implements ExplorationRepository { +``` +그리고 `getPlanets`/`getPlanet`/`getRegions`/`unlockRegion`/`unlockPlanet`/`getProgress`/`clearAll` 각 메서드 위에 `@override`를 추가한다. (시그니처는 이미 인터페이스와 동일하므로 본문 변경 없음) + +- [ ] **Step 4: 로컬 repo 회귀 테스트 작성** + +`test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:space_study_ship/features/exploration/data/datasources/exploration_local_datasource.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_repository_impl.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future build() async { + final prefs = await SharedPreferences.getInstance(); + return ExplorationLocalRepositoryImpl(ExplorationLocalDataSource(prefs)); + } + + test('getPlanets — 시드 반환 (지구 해금 상태)', () async { + final repo = await build(); + final planets = await repo.getPlanets(); + expect(planets, isNotEmpty); + expect(planets.first.id, 'earth'); + expect(planets.first.isUnlocked, true); + }); + + test('unlockRegion — currentFuel 센티넬 -1, 상태 저장', () async { + final repo = await build(); + final result = await repo.unlockRegion('japan'); + expect(result.currentFuel, -1); + final regions = await repo.getRegions('earth'); + expect(regions.firstWhere((r) => r.id == 'japan').isCleared, true); + }); +} +``` + +- [ ] **Step 5: 로컬 repo 테스트 통과 확인** + +Run: `flutter test test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart` +Expected: PASS (2 tests). (이 시점에 provider/화면은 아직 깨져 있으나 이 테스트 파일은 통과) + +> 커밋은 Task 15에서 일괄. 다음 Task로 진행. + +--- + +### Task 9: Provider — 원격 DS provider + repo 분기 + +**Files:** +- Modify: `lib/features/exploration/presentation/providers/exploration_provider.dart` + +- [ ] **Step 1: import 추가** + +파일 상단 import 블록에 추가: + +```dart +import '../../../../core/network/dio_client.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../data/datasources/exploration_remote_datasource.dart'; +import '../../data/repositories/exploration_remote_repository_impl.dart'; +import '../../domain/entities/unlock_result_entities.dart'; +``` + +- [ ] **Step 2: 원격 DS provider 추가 + repo 분기 교체** + +기존 `explorationRepository` provider 블록(주석 포함)을 아래로 교체: + +```dart +// === Remote DataSource === + +@Riverpod(keepAlive: true) +ExplorationRemoteDataSource explorationRemoteDataSource(Ref ref) { + return ExplorationRemoteDataSource(ref.watch(dioProvider)); +} + +// === Repository (auth 기반 스왑) === + +/// 인증 사용자는 서버, 게스트는 로컬 Repository. +@Riverpod(keepAlive: true) +ExplorationRepository explorationRepository(Ref ref) { + if (ref.watch(isAuthenticatedProvider)) { + return ExplorationRemoteRepositoryImpl( + ref.watch(explorationRemoteDataSourceProvider), + ); + } + return ExplorationLocalRepositoryImpl( + ref.watch(explorationLocalDataSourceProvider), + ); +} +``` + +> 본 Task는 Task 10과 함께 빌드되어야 컴파일된다. 계속 진행. + +--- + +### Task 10: Provider — AsyncNotifier 전환 + 해금 분기 + progress + +**Files:** +- Modify: `lib/features/exploration/presentation/providers/exploration_provider.dart` + +- [ ] **Step 1: `ExplorationNotifier`를 AsyncNotifier로 교체** + +기존 `ExplorationNotifier` 클래스 전체를 교체: + +```dart +@Riverpod(keepAlive: true) +class ExplorationNotifier extends _$ExplorationNotifier { + @override + Future> build() async { + return ref.watch(explorationRepositoryProvider).getPlanets(); + } + + /// 이전 행성이 해금되었는지 (sortOrder 기준 바로 앞 행성 클리어 필요). + bool canUnlockPlanet(String planetId) { + final planets = state.valueOrNull ?? const []; + final targetIndex = planets.indexWhere((p) => p.id == planetId); + if (targetIndex < 0) return false; + if (targetIndex == 0) return true; + return planets[targetIndex - 1].isCleared; + } + + bool _isUnlocking = false; + + /// 행성 해금 (순서 검증 + 연료 + 상태). + Future unlockPlanet(String planetId, int requiredFuel) async { + if (_isUnlocking) return; + _isUnlocking = true; + try { + if (!canUnlockPlanet(planetId)) { + throw StateError('이전 행성을 먼저 해금해야 합니다.'); + } + + final repository = ref.read(explorationRepositoryProvider); + if (ref.read(isAuthenticatedProvider)) { + // 서버가 원자적으로 연료 차감 → 잔량 재조회만. + await repository.unlockPlanet(planetId); + ref.invalidate(fuelNotifierProvider); + } else { + // 게스트: 로컬 연료 차감 후 해금. + await ref + .read(fuelNotifierProvider.notifier) + .consumeFuel(amount: requiredFuel, nodeId: planetId); + await repository.unlockPlanet(planetId); + } + + await _reload(); + + try { + await ref.read(badgeNotifierProvider.notifier).checkAndUnlock(); + } catch (e) { + debugPrint('탐험 후 배지 해금 체크 실패: $e'); + } + } finally { + _isUnlocking = false; + } + } + + /// 상태 새로고침 (지역 해금 후 행성 목록 갱신). + Future refresh() => _reload(); + + Future _reload() async { + state = await AsyncValue.guard( + () => ref.read(explorationRepositoryProvider).getPlanets(), + ); + } +} +``` + +- [ ] **Step 2: `RegionListNotifier`를 AsyncNotifier로 교체** + +기존 `RegionListNotifier` 클래스 전체를 교체: + +```dart +@riverpod +class RegionListNotifier extends _$RegionListNotifier { + @override + Future> build(String planetId) async { + return ref.watch(explorationRepositoryProvider).getRegions(planetId); + } + + bool _isUnlocking = false; + + /// 지역 해금 (연료 + 상태 + 자동 클리어). + Future unlockRegion(String regionId, int requiredFuel) async { + if (_isUnlocking) return; + _isUnlocking = true; + try { + final repository = ref.read(explorationRepositoryProvider); + if (ref.read(isAuthenticatedProvider)) { + await repository.unlockRegion(regionId); + ref.invalidate(fuelNotifierProvider); + } else { + await ref + .read(fuelNotifierProvider.notifier) + .consumeFuel(amount: requiredFuel, nodeId: regionId); + await repository.unlockRegion(regionId); + } + + state = await AsyncValue.guard(() => repository.getRegions(planetId)); + + // 행성 목록 갱신 (자동 클리어/progress 반영) + await ref.read(explorationNotifierProvider.notifier).refresh(); + + try { + await ref.read(badgeNotifierProvider.notifier).checkAndUnlock(); + } catch (e) { + debugPrint('탐험 후 배지 해금 체크 실패: $e'); + } + } finally { + _isUnlocking = false; + } + } +} +``` + +- [ ] **Step 3: progress provider를 async로 교체** + +기존 `explorationProgress` provider를 교체: + +```dart +/// 특정 행성의 진행도. +/// +/// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 +/// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 +/// 행성 응답에 내장.) +@riverpod +Future explorationProgress( + Ref ref, + String planetId, +) { + ref.watch(explorationNotifierProvider); + return ref.watch(explorationRepositoryProvider).getProgress(planetId); +} +``` + +- [ ] **Step 4: 코드 생성** + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: `exploration_provider.g.dart` 재생성. (화면 미수정으로 analyze는 아직 오류 — 정상) + +> 커밋은 Task 15. 다음 Task로 진행. + +--- + +### Task 11: auth_provider `_clearGuestData` 순환참조 회피 + +**Files:** +- Modify: `lib/features/auth/presentation/providers/auth_provider.dart` + +- [ ] **Step 1: clearAll 호출을 datasource 직접 호출로 변경** + +`_clearGuestData`의 `clearTasks` 리스트에서 아래 줄을: + +```dart + () => ref.read(explorationRepositoryProvider).clearAll(), +``` + +다음으로 교체: + +```dart + () => ref.read(explorationLocalDataSourceProvider).clearAll(), +``` + +- [ ] **Step 2: import 확인/추가** + +`explorationLocalDataSourceProvider`가 import되어 있지 않으면 import 블록에 추가: + +```dart +import '../../../exploration/presentation/providers/exploration_provider.dart'; +``` + +> (기존에 `explorationRepositoryProvider`를 쓰고 있었으므로 동일 파일 import가 이미 존재할 가능성이 높다. 중복 import 금지 — 없을 때만 추가.) + +> 커밋은 Task 15. 다음 Task로 진행. + +--- + +### Task 12: explore_screen — AsyncValue 대응 + +**Files:** +- Modify: `lib/features/explore/presentation/screens/explore_screen.dart` + +- [ ] **Step 1: import 추가** + +import 블록에 로딩 위젯 추가(미존재 시): + +```dart +import '../../../../core/widgets/feedback/app_loading.dart'; +``` + +- [ ] **Step 2: `build()`를 AsyncValue 분기로 교체** + +`build()` 내부에서 기존: + +```dart + final planets = ref.watch(explorationNotifierProvider); + + // 현재 위치: 가장 마지막으로 해금된 행성 + var currentPlanetId = ''; + for (int i = planets.length - 1; i >= 0; i--) { + if (planets[i].isUnlocked) { + currentPlanetId = planets[i].id; + break; + } + } +``` + +를 다음으로 교체(여기서 `planets` 지역변수 선언 제거, AsyncValue만 보관): + +```dart + final planetsAsync = ref.watch(explorationNotifierProvider); +``` + +그리고 `mapHeight` 계산 블록과 `body:` 를 아래 형태로 바꾼다. 즉 `Scaffold`의 `body:` 를: + +```dart + body: planetsAsync.when( + loading: () => const Center(child: AppLoading()), + error: (_, __) => _buildEmptyState(), + data: (planets) { + if (planets.isEmpty) return _buildEmptyState(); + + var currentPlanetId = ''; + for (int i = planets.length - 1; i >= 0; i--) { + if (planets[i].isUnlocked) { + currentPlanetId = planets[i].id; + break; + } + } + + final topInset = + MediaQuery.of(context).padding.top + kToolbarHeight; + final bottomInset = MediaQuery.of(context).padding.bottom + + FloatingNavMetrics.totalHeight; + final mapHeight = topInset + + _mapTopPadding + + (planets.length - 1) * _planetSpacing + + _mapBottomPadding + + bottomInset; + + return _buildSpaceMap( + context, + ref, + planets, + currentPlanetId, + currentFuel, + isGuest, + mapHeight, + topInset, + ); + }, + ), +``` + +으로 교체한다. (기존 `build()`에서 `topInset`/`bottomInset`/`mapHeight`를 바깥에서 계산하던 코드와 `body: planets.isEmpty ? ... : _buildSpaceMap(...)` 블록은 위 `data:` 콜백 안으로 이동했으므로 **바깥 선언은 삭제**한다. `currentFuel`/`isGuest`는 기존대로 `build()` 상단에서 계산한 값을 사용.) + +- [ ] **Step 3: `_handlePlanetTap`의 목록 read 수정** + +`_handlePlanetTap` 내부의 두 곳: + +```dart + final canUnlock = ref + .read(explorationNotifierProvider.notifier) + .canUnlockPlanet(planet.id); + if (!canUnlock) { + final planets = ref.read(explorationNotifierProvider); +``` + +에서 `ref.read(explorationNotifierProvider)`를: + +```dart + final planets = + ref.read(explorationNotifierProvider).valueOrNull ?? const []; +``` + +로 교체한다. (`canUnlockPlanet`은 notifier 메서드라 변경 불필요) + +- [ ] **Step 4: analyze (이 파일)** + +Run: `flutter analyze lib/features/explore/presentation/screens/explore_screen.dart` +Expected: 이 파일 자체 오류 0 (다른 미수정 화면 오류는 남아 있을 수 있음). + +> 커밋은 Task 15. + +--- + +### Task 13: exploration_detail_screen — AsyncValue 언랩 + +**Files:** +- Modify: `lib/features/exploration/presentation/screens/exploration_detail_screen.dart` + +- [ ] **Step 1: planet select + regions/progress 언랩** + +`build()`의 다음 블록: + +```dart + final planet = ref.watch( + explorationNotifierProvider.select( + (planets) => planets.where((p) => p.id == widget.planetId).firstOrNull, + ), + ); + if (planet == null) { + return const Scaffold(backgroundColor: AppColors.spaceBackground); + } + final regions = ref.watch(regionListNotifierProvider(widget.planetId)); + final progress = ref.watch(explorationProgressProvider(widget.planetId)); +``` + +을 다음으로 교체: + +```dart + final planet = ref.watch( + explorationNotifierProvider.select( + (async) => + async.valueOrNull?.where((p) => p.id == widget.planetId).firstOrNull, + ), + ); + if (planet == null) { + return const Scaffold(backgroundColor: AppColors.spaceBackground); + } + final regions = + ref.watch(regionListNotifierProvider(widget.planetId)).valueOrNull ?? + const []; + final progress = + ref.watch(explorationProgressProvider(widget.planetId)).valueOrNull ?? + ExplorationProgressEntity( + nodeId: widget.planetId, + clearedChildren: 0, + totalChildren: 0, + ); +``` + +- [ ] **Step 2: import 확인** + +`ExplorationProgressEntity`/`ExplorationNodeEntity` import가 없으면 추가: + +```dart +import '../../domain/entities/exploration_node_entity.dart'; +import '../../domain/entities/exploration_progress_entity.dart'; +``` + +- [ ] **Step 3: analyze (이 파일)** + +Run: `flutter analyze lib/features/exploration/presentation/screens/exploration_detail_screen.dart` +Expected: 이 파일 자체 오류 0. + +> 커밋은 Task 15. + +--- + +### Task 14: location_detail_screen — initState 동기 read 제거 + 언랩 + +**Files:** +- Modify: `lib/features/exploration/presentation/screens/location_detail_screen.dart` + +- [ ] **Step 1: initState에서 동기 read 제거** + +기존 `initState`: + +```dart + @override + void initState() { + super.initState(); + // regionListNotifierProvider는 동기 StateNotifier로 항상 즉시 데이터 반환 + final regions = ref.read(regionListNotifierProvider(widget.planetId)); + final initialIndex = regions.indexWhere( + (r) => r.id == widget.initialRegionId, + ); + _currentPage = initialIndex >= 0 ? initialIndex : 0; + _pageController = PageController(initialPage: _currentPage); + } +``` + +을 다음으로 교체(데이터 미준비 가능 → 0으로 시작, 로드 후 점프): + +```dart + bool _didSetInitialPage = false; + + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: 0); + } + + /// regions 로드 후 initialRegionId 페이지로 1회 점프. + void _maybeSetInitialPage(List regions) { + if (_didSetInitialPage || regions.isEmpty) return; + _didSetInitialPage = true; + final initialIndex = regions.indexWhere( + (r) => r.id == widget.initialRegionId, + ); + if (initialIndex > 0) { + _currentPage = initialIndex; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _pageController.hasClients) { + _pageController.jumpToPage(initialIndex); + } + }); + } + } +``` + +- [ ] **Step 2: build()의 언랩 + 초기 페이지 호출** + +`build()`의 다음 블록: + +```dart + final regions = ref.watch(regionListNotifierProvider(widget.planetId)); + final progress = ref.watch(explorationProgressProvider(widget.planetId)); + final currentFuelAsync = ref.watch(currentFuelProvider); + final currentFuel = currentFuelAsync.valueOrNull ?? 0; + final planet = ref.watch( + explorationNotifierProvider.select( + (planets) => planets.where((p) => p.id == widget.planetId).firstOrNull, + ), + ); +``` + +을 다음으로 교체: + +```dart + final regions = + ref.watch(regionListNotifierProvider(widget.planetId)).valueOrNull ?? + const []; + _maybeSetInitialPage(regions); + final progress = + ref.watch(explorationProgressProvider(widget.planetId)).valueOrNull ?? + ExplorationProgressEntity( + nodeId: widget.planetId, + clearedChildren: 0, + totalChildren: 0, + ); + final currentFuelAsync = ref.watch(currentFuelProvider); + final currentFuel = currentFuelAsync.valueOrNull ?? 0; + final planet = ref.watch( + explorationNotifierProvider.select( + (async) => + async.valueOrNull?.where((p) => p.id == widget.planetId).firstOrNull, + ), + ); +``` + +- [ ] **Step 3: import 확인** + +`ExplorationProgressEntity` import 없으면 추가: + +```dart +import '../../domain/entities/exploration_progress_entity.dart'; +``` +(`exploration_node_entity.dart`는 기존 import에 있음 — 중복 추가 금지) + +- [ ] **Step 4: analyze (이 파일)** + +Run: `flutter analyze lib/features/exploration/presentation/screens/location_detail_screen.dart` +Expected: 이 파일 자체 오류 0. + +> 커밋은 Task 15. + +--- + +### Task 15: planet_node 언랩 + 전체 검증 + 커밋 + +**Files:** +- Modify: `lib/features/exploration/presentation/widgets/planet_node.dart` + +- [ ] **Step 1: progress 언랩** + +`planet_node.dart` `build()`의: + +```dart + final progress = ref.watch(explorationProgressProvider(widget.node.id)); +``` + +을 다음으로 교체: + +```dart + final progress = + ref.watch(explorationProgressProvider(widget.node.id)).valueOrNull ?? + ExplorationProgressEntity( + nodeId: widget.node.id, + clearedChildren: 0, + totalChildren: 0, + ); +``` + +- [ ] **Step 2: import 확인** + +`ExplorationProgressEntity` import 없으면 추가: + +```dart +import '../../domain/entities/exploration_progress_entity.dart'; +``` + +- [ ] **Step 3: 코드 생성 + 전체 analyze** + +Run: `flutter pub run build_runner build --delete-conflicting-outputs && flutter analyze` +Expected: **No issues found.** (전 파일 컴파일 그린) + +- [ ] **Step 4: 전체 테스트** + +Run: `flutter test` +Expected: 전체 PASS (신규 + 기존 회귀 포함) + +- [ ] **Step 5: 일괄 Commit (Task 8~15)** + +```bash +git add lib/features/exploration/domain/repositories/exploration_repository.dart \ + lib/features/exploration/data/repositories/exploration_repository_impl.dart \ + lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart \ + lib/features/exploration/presentation/providers/exploration_provider.dart \ + lib/features/exploration/presentation/providers/exploration_provider.g.dart \ + lib/features/auth/presentation/providers/auth_provider.dart \ + lib/features/explore/presentation/screens/explore_screen.dart \ + lib/features/exploration/presentation/screens/exploration_detail_screen.dart \ + lib/features/exploration/presentation/screens/location_detail_screen.dart \ + lib/features/exploration/presentation/widgets/planet_node.dart \ + test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart +git commit -m "feat : 탐험 Repository async 전환 및 서버 연동 (provider·화면)" +``` + +--- + +## Phase 5 — Provider 분기/회귀 테스트 + +### Task 16: explorationRepository 분기 + 게스트 로그아웃 회귀 테스트 + +**Files:** +- Test: `test/features/exploration/presentation/exploration_provider_test.dart` + +- [ ] **Step 1: 테스트 작성** + +`test/features/exploration/presentation/exploration_provider_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:space_study_ship/features/auth/presentation/providers/auth_provider.dart'; +import 'package:space_study_ship/features/exploration/data/datasources/exploration_local_datasource.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_remote_repository_impl.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_repository_impl.dart'; +import 'package:space_study_ship/features/exploration/presentation/providers/exploration_provider.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future container({required bool authed}) async { + final prefs = await SharedPreferences.getInstance(); + return ProviderContainer( + overrides: [ + explorationLocalDataSourceProvider.overrideWithValue( + ExplorationLocalDataSource(prefs), + ), + isAuthenticatedProvider.overrideWith((ref) => authed), + ], + ); + } + + test('게스트 → Local repo 주입', () async { + final c = await container(authed: false); + addTearDown(c.dispose); + expect( + c.read(explorationRepositoryProvider), + isA(), + ); + }); + + test('인증 → Remote repo 주입', () async { + final c = await container(authed: true); + addTearDown(c.dispose); + expect( + c.read(explorationRepositoryProvider), + isA(), + ); + }); + + test('게스트 정리: localDataSource.clearAll 직접 호출 — 순환참조 없음', () async { + // 8.1 회귀: repo 분기 추가 후에도 localDataSource 직접 경로가 동작. + final c = await container(authed: false); + addTearDown(c.dispose); + final ds = c.read(explorationLocalDataSourceProvider); + await ds.saveNodeState( + const ExplorationNodeState(nodeId: 'japan', isUnlocked: true, isCleared: true), + ); + await ds.clearAll(); + expect(ds.getAllStates(), isEmpty); + }); +} +``` + +- [ ] **Step 2: 테스트 통과 확인** + +Run: `flutter test test/features/exploration/presentation/exploration_provider_test.dart` +Expected: PASS (3 tests) + +- [ ] **Step 3: Commit** + +```bash +git add test/features/exploration/presentation/exploration_provider_test.dart +git commit -m "test : 탐험 Repository 분기 및 게스트 정리 회귀 테스트" +``` + +--- + +## 최종 검증 (verification-before-completion) + +- [ ] `flutter pub run build_runner build --delete-conflicting-outputs` 성공 +- [ ] `flutter analyze` — **No issues found** +- [ ] `flutter test` — 전체 PASS +- [ ] (가능 시) 실기기/에뮬레이터: 게스트=지구 해금 동작 불변, 인증=서버 행성 목록 로드/해금 동작 확인 +- [ ] DESIGN.md/상수 규칙 준수 (하드코딩·이모지 없음), 커밋 메시지 Claude 태그 없음 diff --git a/docs/superpowers/specs/2026-05-29-exploration-backend-integration-design.md b/docs/superpowers/specs/2026-05-29-exploration-backend-integration-design.md new file mode 100644 index 0000000..d0ca29e --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-exploration-backend-integration-design.md @@ -0,0 +1,267 @@ +# 행성/지역 해금(Exploration) 백엔드 연동 설계 + +**작성일:** 2026-05-29 +**대상 도메인:** `lib/features/exploration` + 소비처 `lib/features/explore` +**참조 API:** `docs/api-docs.json` — `Exploration` 태그 4개 엔드포인트 + +--- + +## 1. 목표 + +`api-docs.json`에 추가된 Exploration 도메인(행성/지역 해금)을 클라이언트에 연동한다. +**fuel feature와 동일한 패턴**으로, 인증(소셜 로그인) 사용자는 서버를, 게스트는 기존 로컬을 사용한다. + +### 핵심 결정 +- **서버가 source of truth** — 인증 사용자는 카탈로그(이름/아이콘/위치/필요연료)와 해금 상태를 모두 서버에서 받는다. 시드 데이터(`exploration_seed_data.dart`)는 **게스트 전용**으로 남긴다. +- **게스트 독립성** — 로그인 시 마이그레이션/동기화 **없음**. `guest_exploration_states`(SharedPreferences)는 그대로 별개 유지. (fuel/todo와 동일) +- **동기 → async 전환** — 현재 `ExplorationNotifier`는 동기 Notifier(`List` 즉시 반환). 서버 fetch를 위해 AsyncNotifier(`Future`)로 전환하고, 소비 화면 4곳을 `AsyncValue.when()`으로 리팩터한다. + +--- + +## 2. 백엔드 API 매핑 + +| 메서드 | 경로 | 응답 | 비고 | +|---|---|---|---| +| GET | `/api/explorations/planets` | `PlanetResponse[]` | 전체 행성 + 해금/클리어 상태 + `progress`(ProgressDto). sortOrder 오름차순 | +| GET | `/api/explorations/planets/{planetId}/regions` | `RegionResponse[]` | 행성 하위 지역 + 해금 상태. 404 `PLANET_NOT_FOUND` | +| POST | `/api/explorations/regions/{regionId}/unlock` | `RegionUnlockResponse` | 원자적 연료 확인+차감+해금(=클리어). `{region, fuelConsumed, currentFuel, planetCleared}` | +| POST | `/api/explorations/planets/{planetId}/unlock` | `PlanetUnlockResponse` | 원자적. 선행 행성 클리어 필요. `{planet, fuelConsumed, currentFuel}` | + +### unlock 에러 코드 (400/404) +- `INSUFFICIENT_FUEL` — 본문에 `requiredFuel`/`currentFuel` 동봉 +- `ALREADY_UNLOCKED` +- region: `PLANET_LOCKED` / planet: `PREREQUISITE_NOT_CLEARED` +- `REGION_NOT_FOUND` / `PLANET_NOT_FOUND` + +### 서버 응답 ↔ 엔티티 매핑 +- `PlanetResponse`/`RegionResponse` → `ExplorationNodeEntity` + - `nodeType`(string) → `ExplorationNodeType` enum + - `unlockedAt`(string, nullable) → `DateTime?` + - 나머지(name, icon, depth, parentId, requiredFuel, isUnlocked, isCleared, sortOrder, description, mapX, mapY) 1:1 +- `PlanetResponse.progress`(ProgressDto) → `ExplorationProgressEntity` + - 행성 목록 응답에 progress가 내장되므로, 인증 모드 progress는 **지역 재조회 없이** 행성 목록에서 읽는다. + +--- + +## 3. 추가/변경 파일 + +### 3.1 신규 (Data Layer) +``` +data/datasources/exploration_remote_datasource.dart # Retrofit, 4 엔드포인트 +data/models/planet_response_model.dart # + ProgressDto 중첩, toEntity() +data/models/region_response_model.dart # toEntity() +data/models/region_unlock_response_model.dart # + UnlockedNodeDto 중첩 +data/models/planet_unlock_response_model.dart +data/repositories/exploration_remote_repository_impl.dart +``` +모델은 프로젝트 표준대로 **Freezed + JsonSerializable** 사용 (fuel 모델과 동일 스타일). + +### 3.2 신규 (Domain Layer) +``` +domain/entities/region_unlock_result_entity.dart # { planetCleared, currentFuel } +domain/entities/planet_unlock_result_entity.dart # { currentFuel } +domain/exceptions/exploration_exceptions.dart # 아래 4) 참조 +``` +> **엔티티 스타일:** 기존 `ExplorationNodeEntity`/`ExplorationProgressEntity`는 **plain class**(Freezed 아님, 수기 `copyWith`). 신규 result 엔티티 2개도 **plain class**로 맞춘다. DTO 응답 모델(3.1)만 Freezed + JsonSerializable. + +### 3.3 변경 +- `core/constants/api_endpoints.dart` — Exploration 경로 5개 상수 추가 + ```dart + static const explorationPlanets = '/api/explorations/planets'; + static String explorationRegions(String planetId) => '/api/explorations/planets/$planetId/regions'; + static String explorationUnlockRegion(String regionId) => '/api/explorations/regions/$regionId/unlock'; + static String explorationUnlockPlanet(String planetId) => '/api/explorations/planets/$planetId/unlock'; + ``` +- `domain/repositories/exploration_repository.dart` — **전 메서드 async 화** (아래 5) +- `data/repositories/exploration_repository_impl.dart`(로컬) — async 시그니처에 맞춰 `Future`로 래핑 (로직 동일) +- `presentation/providers/exploration_provider.dart` — repo 분기 + AsyncNotifier 전환 +- **`auth/presentation/providers/auth_provider.dart` `_clearGuestData`(~355줄)** — `ref.read(explorationRepositoryProvider).clearAll()` → **`ref.read(explorationLocalDataSourceProvider).clearAll()`** 로 변경 (아래 8.1, 순환참조 회피) +- 소비 화면 4곳 — `AsyncValue` 리팩터 (아래 6·7) + +--- + +## 4. 예외 처리 + +서버 unlock 실패는 **exploration_remote_repository_impl 내부**에서 `DioException` 본문의 `code`를 보고 매핑한다(중앙 `DioExceptionHandler` 대신 도메인 국소 처리 — fuel과 동일 철학). + +### 4.1 연료 부족은 기존 fuel 예외 재사용 (중요) +`INSUFFICIENT_FUEL`은 **새 예외를 만들지 않고** fuel 도메인의 기존 `InsufficientFuelException`(`lib/features/fuel/domain/exceptions/fuel_exceptions.dart`)을 **재사용**한다. + +이유: 해금 UI 공통 진입점 `showUnlockDialog`(`core/utils/unlock_dialog_helper.dart`)가 이미 +```dart +} on InsufficientFuelException catch (e) { + AppSnackBar.error(context, e.toString()); // "연료가 부족합니다 (필요:.., 보유:..)" +``` +로 잡는다. 새 타입을 만들면 이 catch를 타지 못하고 `catch(_)` "해금에 실패했습니다" generic 메시지로 떨어진다. + +```dart +// remote repo 매핑 +throw InsufficientFuelException( + requiredAmount: body['requiredFuel'] as int, + available: body['currentFuel'] as int, +); +``` +> `InsufficientFuelException`은 plain `Exception`(AppException 아님), 필드명은 `requiredAmount`/`available`. + +### 4.2 나머지 exploration 코드 +`domain/exceptions/exploration_exceptions.dart` (plain `Exception` 또는 AppException — UI 표시만 하므로 plain으로 충분): +| 클래스 | 매핑 코드 | 비고 | +|---|---|---| +| `NodeAlreadyUnlockedException` | `ALREADY_UNLOCKED` | | +| `PlanetLockedException` | `PLANET_LOCKED` | 지역 해금 시 상위 행성 잠김 | +| `PrerequisiteNotClearedException` | `PREREQUISITE_NOT_CLEARED` | 선행 행성 미클리어 | +| `ExplorationNodeNotFoundException` | `REGION_NOT_FOUND`/`PLANET_NOT_FOUND` | | + +화면은 두 경로(게스트/서버) 모두 unlock 전 **사전 검증**(연료/순서)으로 이미 차단하므로, 이 예외들은 **레이스 상황의 안전망**이다. + +### 4.3 다이얼로그 에러 처리는 이미 존재 (신규 작업 아님) +`showUnlockDialog`가 `onUnlock` 호출을 이미 try/catch로 감싼다. 신규 예외(`ALREADY_UNLOCKED` 등)는 현재 `catch(_)` → "해금에 실패했습니다"로 떨어지며, 더 친절한 안내가 필요하면 `showUnlockDialog`에 `on NodeAlreadyUnlockedException` 등 케이스를 **추가**한다(화면 코드 변경 아님). + +매핑 외 DioException은 기존 `DioExceptionHandler.handle(e)`로 위임(네트워크/타임아웃/401 등 일관 처리). + +--- + +## 5. Repository 인터페이스 (async 전환) + +```dart +abstract class ExplorationRepository { + Future> getPlanets(); + Future getPlanet(String planetId); + Future> getRegions(String planetId); + Future unlockRegion(String regionId); + Future unlockPlanet(String planetId); + Future getProgress(String planetId); + Future clearAll(); +} +``` + +### 로컬 구현 (게스트) +- 기존 동기 로직을 `Future`로 래핑 (시드 + SharedPreferences 머지). +- `unlockRegion`: 기존처럼 해금=클리어 저장 + 행성 자동 클리어 체크 → `RegionUnlockResultEntity(planetCleared, currentFuel: -1)`. 게스트는 연료를 Notifier가 별도 차감하므로 `currentFuel`은 미사용(센티넬). +- `unlockPlanet` → `PlanetUnlockResultEntity(currentFuel: -1)`. + +### 원격 구현 (인증) +- `getPlanets`/`getRegions`: API fetch → `toEntity()`. 네트워크 실패 시 정책은 **6.4** 참조. +- `getProgress(planetId)`: **최근 getPlanets 결과(in-memory 캐시)의 해당 행성 progress**를 반환. 캐시 미스 시 `getPlanets()` 1회 호출 후 조회. +- `unlockRegion`/`unlockPlanet`: API 호출 → 결과 엔티티. 실패 시 4) 매핑. +- `clearAll`: in-memory 캐시만 비움. **호출 경로 없음(dead)** — 회원 로그아웃/탈퇴는 서버가 데이터 정리, 게스트 정리는 localDataSource 직접(8.1). 인터페이스 만족을 위해 둔다. + +--- + +## 6. Provider & 해금 흐름 + +### 6.1 Repository 분기 (fuel 패턴 복제) +```dart +@Riverpod(keepAlive: true) +ExplorationRemoteDataSource explorationRemoteDataSource(Ref ref) => + ExplorationRemoteDataSource(ref.watch(dioProvider)); + +@Riverpod(keepAlive: true) +ExplorationRepository explorationRepository(Ref ref) { + if (ref.watch(isAuthenticatedProvider)) { + return ExplorationRemoteRepositoryImpl(ref.watch(explorationRemoteDataSourceProvider)); + } + return ExplorationLocalRepositoryImpl(ref.watch(explorationLocalDataSourceProvider)); +} +``` +> `isAuthenticatedProvider`는 게스트면 false. 로그인 상태 변화 시 repo가 자동 스왑되고 Notifier가 재빌드된다. + +### 6.2 ExplorationNotifier (동기 → AsyncNotifier) +```dart +@Riverpod(keepAlive: true) +class ExplorationNotifier extends _$ExplorationNotifier { + @override + Future> build() async => + ref.watch(explorationRepositoryProvider).getPlanets(); + // ... +} +``` +- `canUnlockPlanet`: `state.valueOrNull ?? []`에서 sortOrder 검증. +- `unlockPlanet(planetId, requiredFuel)` — isAuthenticated 분기: + - **게스트:** 기존 그대로 — `fuelNotifier.consumeFuel()`(로컬 차감) → `repo.unlockPlanet()` → reload. + - **인증:** 사전 차감 없음 — `repo.unlockPlanet()`(서버 원자 차감) → `ref.invalidate(fuelNotifierProvider)`(잔량 재조회) → reload. + - 공통: 성공 후 배지 체크(`badgeNotifier.checkAndUnlock`, 실패 무시), `ref.invalidateSelf()`로 목록 재조회. + +### 6.3 RegionListNotifier (family, async 전환) +```dart +Future> build(String planetId) async => + ref.watch(explorationRepositoryProvider).getRegions(planetId); +``` +- `unlockRegion(regionId, requiredFuel)` — 게스트/인증 분기는 `unlockPlanet`과 동일 원칙. + - 성공 후 지역 목록 재조회 + `explorationNotifier` 재조회(행성 자동 클리어/progress 반영). + +### 6.4 progress provider (async 전환) +```dart +@riverpod +Future explorationProgress(Ref ref, String planetId) { + ref.watch(explorationNotifierProvider); // 행성 목록 변경 시 갱신 + return ref.watch(explorationRepositoryProvider).getProgress(planetId); +} +``` +> **주의:** 그리드의 `planet_node`가 행성마다 progress를 조회한다. `regionListNotifier`를 watch하면 **인증 모드에서 행성 수만큼 지역 API가 호출**된다(서버 progress는 행성 목록에 내장되어 불필요). 대신 `explorationNotifierProvider`(행성 목록)를 watch한다 — 인증 모드는 목록 재조회로 progress 갱신, 게스트는 지역 unlock 후 `explorationNotifier.refresh()`(6.3)가 호출되므로 동일하게 재계산된다. 인증 `getProgress`는 행성 캐시에서 즉시 반환(추가 fetch 없음). + +### 6.5 네트워크 실패 정책 (인증 모드) +- `getPlanets`/`getRegions` 실패 → 예외 전파 → 화면 `.when(error:)`에서 재시도 UI 노출. (랭킹 등 Tier 3와 동일하게 "서버 우선, 실패 시 사용자 안내") +- unlock 실패 → 4) 예외 → 화면에서 SnackBar 안내, 상태 미변경. + +--- + +## 7. 화면 리팩터 (4곳) — 단순 `.when()` 이상, 동기 의존 제거 포함 + +`ExplorationNotifier`/`RegionListNotifier`/`explorationProgress`가 `AsyncValue`로 바뀌면서, 화면들이 **동기 List/progress에 의존하던 지점**을 모두 풀어야 한다. 단순 래핑보다 범위가 크다. + +| 파일 | 변경 | +|---|---| +| `explore/.../explore_screen.dart` | `build()`가 `planets`로 `currentPlanetId` 루프 + `mapHeight` 계산 + `planets.isEmpty`를 함 → **데이터 분기 전체를 `.when()` 안으로** 이동. `canUnlockPlanet`/목록 read는 `state.valueOrNull ?? []`. (unlock 예외 처리는 `showUnlockDialog`가 이미 담당 — 화면 추가 불필요) | +| `exploration/.../exploration_detail_screen.dart` | `explorationNotifierProvider.select((planets)=>...)` → **`AsyncValue` 기준 셀렉터**(`.select((a)=>a.valueOrNull?.where(...).firstOrNull)`). `regions`/`progress`를 빌더(`_buildTicketFront(planet, progress)` 등)에 직접 넘기던 것 → **언랩 후 전달**(`.valueOrNull`/`.when()`) | +| `exploration/.../location_detail_screen.dart` | **`initState()`의 동기 `ref.read(regionListNotifierProvider)`로 PageController 초기 페이지 계산 → 재구성 필요**(async라 initState 시점에 데이터 없음). 데이터 로드 후 `initialRegionId`로 페이지 설정(`ref.listen` 또는 `.when` data 콜백에서 `jumpToPage`). regions/progress/`.select` 동일 언랩 | +| `exploration/.../widgets/planet_node.dart` | `progress` → `AsyncValue` 언랩 (로딩 시 0 진행도 placeholder) | + +로딩/에러 UI는 기존 공통 위젯(`AppLoading`, `SpaceEmptyState`/SnackBar) 재사용. **하드코딩·이모지 금지** 규칙 준수. + +--- + +## 8. 게스트 모드 보장 (회귀 방지) + +### 8.1 게스트 로그아웃 정리 — 순환참조 회피 (필수) +`auth_provider.dart`의 `_clearGuestData`는 현재 게스트 정리에서 +```dart +() => ref.read(explorationRepositoryProvider).clearAll(), +``` +를 호출한다. `explorationRepositoryProvider`에 `isAuthenticatedProvider` watch를 추가하면, `authNotifier` 내부에서 이 repo를 read할 때 **CircularDependencyError**가 발생한다(같은 파일 주석이 todo/fuel에서 이미 경고 — 그래서 그들은 `localTodoDataSourceProvider`/`fuelLocalDataSourceProvider`를 **직접** 호출). + +**변경:** 해당 줄을 datasource 직접 호출로 교체한다. +```dart +() => ref.read(explorationLocalDataSourceProvider).clearAll(), +``` +> 게스트 정리는 로컬 상태만 지우면 되므로 datasource 직접 호출이 의미상으로도 정확하다. (이 변경 없이 분기 추가 시 기존 게스트 로그아웃이 즉시 깨짐) + +### 8.2 기타 +- 게스트(`isAuthenticated == false`)는 기존 `ExplorationLocalRepositoryImpl` 경로 100% 유지. unlock 흐름·시드·SharedPreferences 동작 불변. +- 게스트는 지구만 접근 가능(비-지구 행성은 로그인 유도) — 기존 explore_screen 로직 유지. +- 로그인↔게스트 전환 시 데이터 교차 없음. + +--- + +## 9. 테스트 계획 (TDD) + +**Data Layer (unit):** +- `planet_response_model`/`region_response_model` `fromJson`/`toEntity` 매핑 (nodeType enum, unlockedAt null/non-null, progress). +- `region_unlock_response_model`/`planet_unlock_response_model` 파싱. +- `ExplorationRemoteRepositoryImpl`: 성공 매핑, unlock 에러코드→예외 매핑 — 특히 **`INSUFFICIENT_FUEL` → 기존 `InsufficientFuelException(requiredAmount, available)`** 재사용 검증(본문 `requiredFuel`/`currentFuel` 파싱), getProgress 캐시 동작 (mocktail로 DataSource mock). +- `ExplorationLocalRepositoryImpl`: async 시그니처 회귀 (기존 동작 유지). + +**Provider (unit):** +- `explorationRepository` 분기 (인증 true→Remote, false→Local) — auth override. +- `ExplorationNotifier`/`RegionListNotifier` 인증/게스트 unlock 분기 (게스트=consumeFuel 호출, 인증=fuel invalidate, consumeFuel 미호출). +- **게스트 로그아웃 회귀(8.1):** repo 분기 추가 후에도 게스트 `signOut`이 CircularDependencyError 없이 exploration 로컬 상태를 비우는지 (auth_provider `_clearGuestData` 통합 테스트 또는 datasource 직접 호출 검증). + +**검증:** `flutter analyze` 0 경고, `flutter test` 전체 통과, 게스트 회귀 통과. + +--- + +## 10. 범위 외 (YAGNI) +- 게스트→서버 데이터 마이그레이션 +- 오프라인 unlock 큐잉/재시도 +- galaxy/starSystem 계층 (확장용 enum만 존재, 미사용) +- 서버 카탈로그 변경 실시간 푸시 From 967d4aa92f9aa6281cfa9a0da1b1f4d6fd6e60e7 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 18:53:53 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat=20:=20Exploration=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=83=81=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/constants/api_endpoints.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 3d4f1ae..1930ade 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -68,4 +68,23 @@ abstract class ApiEndpoints { /// 오늘 공부 통계 (KST) static const timerSessionsTodayStats = '/api/timer-sessions/today-stats'; + + // ============================================ + // Exploration + // ============================================ + + /// 전체 행성 목록 + static const explorationPlanets = '/api/explorations/planets'; + + /// 특정 행성 하위 지역 목록 + static String explorationRegions(String planetId) => + '/api/explorations/planets/$planetId/regions'; + + /// 지역 해금 (연료 소비) + static String explorationUnlockRegion(String regionId) => + '/api/explorations/regions/$regionId/unlock'; + + /// 행성 해금 (연료 소비) + static String explorationUnlockPlanet(String planetId) => + '/api/explorations/planets/$planetId/unlock'; } From b900267a496e41417184be6f752c1e873f0807f1 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 18:55:58 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat=20:=20=ED=83=90=ED=97=98=20=ED=95=B4?= =?UTF-8?q?=EA=B8=88=20=EA=B2=B0=EA=B3=BC=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/unlock_result_entities.dart | 24 +++++++++++ .../exceptions/exploration_exceptions.dart | 41 +++++++++++++++++++ .../domain/unlock_result_entities_test.dart | 20 +++++++++ 3 files changed, 85 insertions(+) create mode 100644 lib/features/exploration/domain/entities/unlock_result_entities.dart create mode 100644 lib/features/exploration/domain/exceptions/exploration_exceptions.dart create mode 100644 test/features/exploration/domain/unlock_result_entities_test.dart diff --git a/lib/features/exploration/domain/entities/unlock_result_entities.dart b/lib/features/exploration/domain/entities/unlock_result_entities.dart new file mode 100644 index 0000000..fd0f4f4 --- /dev/null +++ b/lib/features/exploration/domain/entities/unlock_result_entities.dart @@ -0,0 +1,24 @@ +/// 지역 해금 결과 +/// +/// 게스트는 연료를 Notifier가 별도 차감하므로 [currentFuel]은 `-1`(미사용 센티넬). +/// 인증(서버) 경로는 서버가 차감 후 반환한 잔량을 담는다. +class RegionUnlockResultEntity { + const RegionUnlockResultEntity({ + required this.planetCleared, + required this.currentFuel, + }); + + /// 이 지역 해금으로 상위 행성이 모두 클리어되었는지 + final bool planetCleared; + + /// 해금 후 잔여 연료 (게스트: -1 센티넬) + final int currentFuel; +} + +/// 행성 해금 결과 +class PlanetUnlockResultEntity { + const PlanetUnlockResultEntity({required this.currentFuel}); + + /// 해금 후 잔여 연료 (게스트: -1 센티넬) + final int currentFuel; +} diff --git a/lib/features/exploration/domain/exceptions/exploration_exceptions.dart b/lib/features/exploration/domain/exceptions/exploration_exceptions.dart new file mode 100644 index 0000000..c6f50ad --- /dev/null +++ b/lib/features/exploration/domain/exceptions/exploration_exceptions.dart @@ -0,0 +1,41 @@ +// 탐험 해금 도메인 예외. +// +// 연료 부족(`INSUFFICIENT_FUEL`)은 별도 클래스를 만들지 않고 fuel 도메인의 +// 기존 `InsufficientFuelException`을 재사용한다 (showUnlockDialog가 이미 catch). +// 아래 예외들은 화면 사전검증을 통과한 레이스 상황의 안전망이다. + +/// 이미 해금된 노드 (`ALREADY_UNLOCKED`) +class NodeAlreadyUnlockedException implements Exception { + const NodeAlreadyUnlockedException([this.message = '이미 해금된 곳입니다.']); + final String message; + @override + String toString() => 'NodeAlreadyUnlockedException: $message'; +} + +/// 상위 행성이 잠겨 지역을 해금할 수 없음 (`PLANET_LOCKED`) +class PlanetLockedException implements Exception { + const PlanetLockedException([this.message = '먼저 행성을 해금해야 합니다.']); + final String message; + @override + String toString() => 'PlanetLockedException: $message'; +} + +/// 선행 행성 미클리어로 해금 불가 (`PREREQUISITE_NOT_CLEARED`) +class PrerequisiteNotClearedException implements Exception { + const PrerequisiteNotClearedException([ + this.message = '이전 행성을 먼저 클리어해야 합니다.', + ]); + final String message; + @override + String toString() => 'PrerequisiteNotClearedException: $message'; +} + +/// 노드 미존재 (`REGION_NOT_FOUND` / `PLANET_NOT_FOUND`) +class ExplorationNodeNotFoundException implements Exception { + const ExplorationNodeNotFoundException([ + this.message = '해당 탐험 지점을 찾을 수 없습니다.', + ]); + final String message; + @override + String toString() => 'ExplorationNodeNotFoundException: $message'; +} diff --git a/test/features/exploration/domain/unlock_result_entities_test.dart b/test/features/exploration/domain/unlock_result_entities_test.dart new file mode 100644 index 0000000..8c49397 --- /dev/null +++ b/test/features/exploration/domain/unlock_result_entities_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/domain/entities/unlock_result_entities.dart'; + +void main() { + test('RegionUnlockResultEntity — 필드 보관', () { + const r = RegionUnlockResultEntity(planetCleared: true, currentFuel: 42); + expect(r.planetCleared, true); + expect(r.currentFuel, 42); + }); + + test('PlanetUnlockResultEntity — 필드 보관', () { + const p = PlanetUnlockResultEntity(currentFuel: 7); + expect(p.currentFuel, 7); + }); + + test('게스트 센티넬 currentFuel = -1 허용', () { + const r = RegionUnlockResultEntity(planetCleared: false, currentFuel: -1); + expect(r.currentFuel, -1); + }); +} From c9399c62e85ce76ede8a42a487c7ef55209ed7a3 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 18:58:04 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat=20:=20PlanetResponseModel=20DTO=20?= =?UTF-8?q?=EB=B0=8F=20enum=20=EB=A7=A4=ED=95=91=20=EC=B6=94=EA=B0=80=20#8?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/models/planet_response_model.dart | 88 +++ .../models/planet_response_model.freezed.dart | 743 ++++++++++++++++++ .../data/models/planet_response_model.g.dart | 71 ++ .../models/planet_response_model_test.dart | 65 ++ 4 files changed, 967 insertions(+) create mode 100644 lib/features/exploration/data/models/planet_response_model.dart create mode 100644 lib/features/exploration/data/models/planet_response_model.freezed.dart create mode 100644 lib/features/exploration/data/models/planet_response_model.g.dart create mode 100644 test/features/exploration/data/models/planet_response_model_test.dart diff --git a/lib/features/exploration/data/models/planet_response_model.dart b/lib/features/exploration/data/models/planet_response_model.dart new file mode 100644 index 0000000..84010f0 --- /dev/null +++ b/lib/features/exploration/data/models/planet_response_model.dart @@ -0,0 +1,88 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/exploration_node_entity.dart'; +import '../../domain/entities/exploration_progress_entity.dart'; + +part 'planet_response_model.freezed.dart'; +part 'planet_response_model.g.dart'; + +/// GET /api/explorations/planets 응답 항목 DTO +@freezed +class PlanetResponseModel with _$PlanetResponseModel { + const PlanetResponseModel._(); + + const factory PlanetResponseModel({ + required String id, + required String name, + required String nodeType, + required int depth, + required String icon, + String? parentId, + String? prerequisiteId, + required int requiredFuel, + required bool isUnlocked, + required bool isCleared, + required int sortOrder, + @Default('') String description, + @Default(0.5) double mapX, + @Default(0.0) double mapY, + DateTime? unlockedAt, + ProgressResponseModel? progress, + }) = _PlanetResponseModel; + + factory PlanetResponseModel.fromJson(Map json) => + _$PlanetResponseModelFromJson(json); + + ExplorationNodeEntity toEntity() => ExplorationNodeEntity( + id: id, + name: name, + nodeType: nodeTypeFromString(nodeType), + depth: depth, + icon: icon, + parentId: parentId, + requiredFuel: requiredFuel, + isUnlocked: isUnlocked, + isCleared: isCleared, + sortOrder: sortOrder, + description: description, + mapX: mapX, + mapY: mapY, + unlockedAt: unlockedAt, + ); + + ExplorationProgressEntity progressEntity() => ExplorationProgressEntity( + nodeId: id, + clearedChildren: progress?.clearedChildren ?? 0, + totalChildren: progress?.totalChildren ?? 0, + ); +} + +/// 행성 진행도 중첩 DTO +@freezed +class ProgressResponseModel with _$ProgressResponseModel { + const factory ProgressResponseModel({ + required int clearedChildren, + required int totalChildren, + @Default(0.0) double progressRatio, + }) = _ProgressResponseModel; + + factory ProgressResponseModel.fromJson(Map json) => + _$ProgressResponseModelFromJson(json); +} + +/// 서버 nodeType 문자열 → enum 매핑 (재사용) +ExplorationNodeType nodeTypeFromString(String raw) { + switch (raw) { + case 'galaxy': + return ExplorationNodeType.galaxy; + case 'starSystem': + case 'star_system': + return ExplorationNodeType.starSystem; + case 'planet': + return ExplorationNodeType.planet; + case 'region': + return ExplorationNodeType.region; + default: + return ExplorationNodeType.region; + } +} diff --git a/lib/features/exploration/data/models/planet_response_model.freezed.dart b/lib/features/exploration/data/models/planet_response_model.freezed.dart new file mode 100644 index 0000000..ac82234 --- /dev/null +++ b/lib/features/exploration/data/models/planet_response_model.freezed.dart @@ -0,0 +1,743 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'planet_response_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +PlanetResponseModel _$PlanetResponseModelFromJson(Map json) { + return _PlanetResponseModel.fromJson(json); +} + +/// @nodoc +mixin _$PlanetResponseModel { + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get nodeType => throw _privateConstructorUsedError; + int get depth => throw _privateConstructorUsedError; + String get icon => throw _privateConstructorUsedError; + String? get parentId => throw _privateConstructorUsedError; + String? get prerequisiteId => throw _privateConstructorUsedError; + int get requiredFuel => throw _privateConstructorUsedError; + bool get isUnlocked => throw _privateConstructorUsedError; + bool get isCleared => throw _privateConstructorUsedError; + int get sortOrder => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + double get mapX => throw _privateConstructorUsedError; + double get mapY => throw _privateConstructorUsedError; + DateTime? get unlockedAt => throw _privateConstructorUsedError; + ProgressResponseModel? get progress => throw _privateConstructorUsedError; + + /// Serializes this PlanetResponseModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of PlanetResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $PlanetResponseModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlanetResponseModelCopyWith<$Res> { + factory $PlanetResponseModelCopyWith( + PlanetResponseModel value, + $Res Function(PlanetResponseModel) then, + ) = _$PlanetResponseModelCopyWithImpl<$Res, PlanetResponseModel>; + @useResult + $Res call({ + String id, + String name, + String nodeType, + int depth, + String icon, + String? parentId, + String? prerequisiteId, + int requiredFuel, + bool isUnlocked, + bool isCleared, + int sortOrder, + String description, + double mapX, + double mapY, + DateTime? unlockedAt, + ProgressResponseModel? progress, + }); + + $ProgressResponseModelCopyWith<$Res>? get progress; +} + +/// @nodoc +class _$PlanetResponseModelCopyWithImpl<$Res, $Val extends PlanetResponseModel> + implements $PlanetResponseModelCopyWith<$Res> { + _$PlanetResponseModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of PlanetResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? nodeType = null, + Object? depth = null, + Object? icon = null, + Object? parentId = freezed, + Object? prerequisiteId = freezed, + Object? requiredFuel = null, + Object? isUnlocked = null, + Object? isCleared = null, + Object? sortOrder = null, + Object? description = null, + Object? mapX = null, + Object? mapY = null, + Object? unlockedAt = freezed, + Object? progress = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + nodeType: null == nodeType + ? _value.nodeType + : nodeType // ignore: cast_nullable_to_non_nullable + as String, + depth: null == depth + ? _value.depth + : depth // ignore: cast_nullable_to_non_nullable + as int, + icon: null == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String, + parentId: freezed == parentId + ? _value.parentId + : parentId // ignore: cast_nullable_to_non_nullable + as String?, + prerequisiteId: freezed == prerequisiteId + ? _value.prerequisiteId + : prerequisiteId // ignore: cast_nullable_to_non_nullable + as String?, + requiredFuel: null == requiredFuel + ? _value.requiredFuel + : requiredFuel // ignore: cast_nullable_to_non_nullable + as int, + isUnlocked: null == isUnlocked + ? _value.isUnlocked + : isUnlocked // ignore: cast_nullable_to_non_nullable + as bool, + isCleared: null == isCleared + ? _value.isCleared + : isCleared // ignore: cast_nullable_to_non_nullable + as bool, + sortOrder: null == sortOrder + ? _value.sortOrder + : sortOrder // ignore: cast_nullable_to_non_nullable + as int, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + mapX: null == mapX + ? _value.mapX + : mapX // ignore: cast_nullable_to_non_nullable + as double, + mapY: null == mapY + ? _value.mapY + : mapY // ignore: cast_nullable_to_non_nullable + as double, + unlockedAt: freezed == unlockedAt + ? _value.unlockedAt + : unlockedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + progress: freezed == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as ProgressResponseModel?, + ) + as $Val, + ); + } + + /// Create a copy of PlanetResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ProgressResponseModelCopyWith<$Res>? get progress { + if (_value.progress == null) { + return null; + } + + return $ProgressResponseModelCopyWith<$Res>(_value.progress!, (value) { + return _then(_value.copyWith(progress: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$PlanetResponseModelImplCopyWith<$Res> + implements $PlanetResponseModelCopyWith<$Res> { + factory _$$PlanetResponseModelImplCopyWith( + _$PlanetResponseModelImpl value, + $Res Function(_$PlanetResponseModelImpl) then, + ) = __$$PlanetResponseModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String id, + String name, + String nodeType, + int depth, + String icon, + String? parentId, + String? prerequisiteId, + int requiredFuel, + bool isUnlocked, + bool isCleared, + int sortOrder, + String description, + double mapX, + double mapY, + DateTime? unlockedAt, + ProgressResponseModel? progress, + }); + + @override + $ProgressResponseModelCopyWith<$Res>? get progress; +} + +/// @nodoc +class __$$PlanetResponseModelImplCopyWithImpl<$Res> + extends _$PlanetResponseModelCopyWithImpl<$Res, _$PlanetResponseModelImpl> + implements _$$PlanetResponseModelImplCopyWith<$Res> { + __$$PlanetResponseModelImplCopyWithImpl( + _$PlanetResponseModelImpl _value, + $Res Function(_$PlanetResponseModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of PlanetResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? nodeType = null, + Object? depth = null, + Object? icon = null, + Object? parentId = freezed, + Object? prerequisiteId = freezed, + Object? requiredFuel = null, + Object? isUnlocked = null, + Object? isCleared = null, + Object? sortOrder = null, + Object? description = null, + Object? mapX = null, + Object? mapY = null, + Object? unlockedAt = freezed, + Object? progress = freezed, + }) { + return _then( + _$PlanetResponseModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + nodeType: null == nodeType + ? _value.nodeType + : nodeType // ignore: cast_nullable_to_non_nullable + as String, + depth: null == depth + ? _value.depth + : depth // ignore: cast_nullable_to_non_nullable + as int, + icon: null == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String, + parentId: freezed == parentId + ? _value.parentId + : parentId // ignore: cast_nullable_to_non_nullable + as String?, + prerequisiteId: freezed == prerequisiteId + ? _value.prerequisiteId + : prerequisiteId // ignore: cast_nullable_to_non_nullable + as String?, + requiredFuel: null == requiredFuel + ? _value.requiredFuel + : requiredFuel // ignore: cast_nullable_to_non_nullable + as int, + isUnlocked: null == isUnlocked + ? _value.isUnlocked + : isUnlocked // ignore: cast_nullable_to_non_nullable + as bool, + isCleared: null == isCleared + ? _value.isCleared + : isCleared // ignore: cast_nullable_to_non_nullable + as bool, + sortOrder: null == sortOrder + ? _value.sortOrder + : sortOrder // ignore: cast_nullable_to_non_nullable + as int, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + mapX: null == mapX + ? _value.mapX + : mapX // ignore: cast_nullable_to_non_nullable + as double, + mapY: null == mapY + ? _value.mapY + : mapY // ignore: cast_nullable_to_non_nullable + as double, + unlockedAt: freezed == unlockedAt + ? _value.unlockedAt + : unlockedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + progress: freezed == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as ProgressResponseModel?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlanetResponseModelImpl extends _PlanetResponseModel { + const _$PlanetResponseModelImpl({ + required this.id, + required this.name, + required this.nodeType, + required this.depth, + required this.icon, + this.parentId, + this.prerequisiteId, + required this.requiredFuel, + required this.isUnlocked, + required this.isCleared, + required this.sortOrder, + this.description = '', + this.mapX = 0.5, + this.mapY = 0.0, + this.unlockedAt, + this.progress, + }) : super._(); + + factory _$PlanetResponseModelImpl.fromJson(Map json) => + _$$PlanetResponseModelImplFromJson(json); + + @override + final String id; + @override + final String name; + @override + final String nodeType; + @override + final int depth; + @override + final String icon; + @override + final String? parentId; + @override + final String? prerequisiteId; + @override + final int requiredFuel; + @override + final bool isUnlocked; + @override + final bool isCleared; + @override + final int sortOrder; + @override + @JsonKey() + final String description; + @override + @JsonKey() + final double mapX; + @override + @JsonKey() + final double mapY; + @override + final DateTime? unlockedAt; + @override + final ProgressResponseModel? progress; + + @override + String toString() { + return 'PlanetResponseModel(id: $id, name: $name, nodeType: $nodeType, depth: $depth, icon: $icon, parentId: $parentId, prerequisiteId: $prerequisiteId, requiredFuel: $requiredFuel, isUnlocked: $isUnlocked, isCleared: $isCleared, sortOrder: $sortOrder, description: $description, mapX: $mapX, mapY: $mapY, unlockedAt: $unlockedAt, progress: $progress)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlanetResponseModelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.nodeType, nodeType) || + other.nodeType == nodeType) && + (identical(other.depth, depth) || other.depth == depth) && + (identical(other.icon, icon) || other.icon == icon) && + (identical(other.parentId, parentId) || + other.parentId == parentId) && + (identical(other.prerequisiteId, prerequisiteId) || + other.prerequisiteId == prerequisiteId) && + (identical(other.requiredFuel, requiredFuel) || + other.requiredFuel == requiredFuel) && + (identical(other.isUnlocked, isUnlocked) || + other.isUnlocked == isUnlocked) && + (identical(other.isCleared, isCleared) || + other.isCleared == isCleared) && + (identical(other.sortOrder, sortOrder) || + other.sortOrder == sortOrder) && + (identical(other.description, description) || + other.description == description) && + (identical(other.mapX, mapX) || other.mapX == mapX) && + (identical(other.mapY, mapY) || other.mapY == mapY) && + (identical(other.unlockedAt, unlockedAt) || + other.unlockedAt == unlockedAt) && + (identical(other.progress, progress) || + other.progress == progress)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + name, + nodeType, + depth, + icon, + parentId, + prerequisiteId, + requiredFuel, + isUnlocked, + isCleared, + sortOrder, + description, + mapX, + mapY, + unlockedAt, + progress, + ); + + /// Create a copy of PlanetResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PlanetResponseModelImplCopyWith<_$PlanetResponseModelImpl> get copyWith => + __$$PlanetResponseModelImplCopyWithImpl<_$PlanetResponseModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$PlanetResponseModelImplToJson(this); + } +} + +abstract class _PlanetResponseModel extends PlanetResponseModel { + const factory _PlanetResponseModel({ + required final String id, + required final String name, + required final String nodeType, + required final int depth, + required final String icon, + final String? parentId, + final String? prerequisiteId, + required final int requiredFuel, + required final bool isUnlocked, + required final bool isCleared, + required final int sortOrder, + final String description, + final double mapX, + final double mapY, + final DateTime? unlockedAt, + final ProgressResponseModel? progress, + }) = _$PlanetResponseModelImpl; + const _PlanetResponseModel._() : super._(); + + factory _PlanetResponseModel.fromJson(Map json) = + _$PlanetResponseModelImpl.fromJson; + + @override + String get id; + @override + String get name; + @override + String get nodeType; + @override + int get depth; + @override + String get icon; + @override + String? get parentId; + @override + String? get prerequisiteId; + @override + int get requiredFuel; + @override + bool get isUnlocked; + @override + bool get isCleared; + @override + int get sortOrder; + @override + String get description; + @override + double get mapX; + @override + double get mapY; + @override + DateTime? get unlockedAt; + @override + ProgressResponseModel? get progress; + + /// Create a copy of PlanetResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PlanetResponseModelImplCopyWith<_$PlanetResponseModelImpl> get copyWith => + throw _privateConstructorUsedError; +} + +ProgressResponseModel _$ProgressResponseModelFromJson( + Map json, +) { + return _ProgressResponseModel.fromJson(json); +} + +/// @nodoc +mixin _$ProgressResponseModel { + int get clearedChildren => throw _privateConstructorUsedError; + int get totalChildren => throw _privateConstructorUsedError; + double get progressRatio => throw _privateConstructorUsedError; + + /// Serializes this ProgressResponseModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ProgressResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProgressResponseModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProgressResponseModelCopyWith<$Res> { + factory $ProgressResponseModelCopyWith( + ProgressResponseModel value, + $Res Function(ProgressResponseModel) then, + ) = _$ProgressResponseModelCopyWithImpl<$Res, ProgressResponseModel>; + @useResult + $Res call({int clearedChildren, int totalChildren, double progressRatio}); +} + +/// @nodoc +class _$ProgressResponseModelCopyWithImpl< + $Res, + $Val extends ProgressResponseModel +> + implements $ProgressResponseModelCopyWith<$Res> { + _$ProgressResponseModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ProgressResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? clearedChildren = null, + Object? totalChildren = null, + Object? progressRatio = null, + }) { + return _then( + _value.copyWith( + clearedChildren: null == clearedChildren + ? _value.clearedChildren + : clearedChildren // ignore: cast_nullable_to_non_nullable + as int, + totalChildren: null == totalChildren + ? _value.totalChildren + : totalChildren // ignore: cast_nullable_to_non_nullable + as int, + progressRatio: null == progressRatio + ? _value.progressRatio + : progressRatio // ignore: cast_nullable_to_non_nullable + as double, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ProgressResponseModelImplCopyWith<$Res> + implements $ProgressResponseModelCopyWith<$Res> { + factory _$$ProgressResponseModelImplCopyWith( + _$ProgressResponseModelImpl value, + $Res Function(_$ProgressResponseModelImpl) then, + ) = __$$ProgressResponseModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({int clearedChildren, int totalChildren, double progressRatio}); +} + +/// @nodoc +class __$$ProgressResponseModelImplCopyWithImpl<$Res> + extends + _$ProgressResponseModelCopyWithImpl<$Res, _$ProgressResponseModelImpl> + implements _$$ProgressResponseModelImplCopyWith<$Res> { + __$$ProgressResponseModelImplCopyWithImpl( + _$ProgressResponseModelImpl _value, + $Res Function(_$ProgressResponseModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ProgressResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? clearedChildren = null, + Object? totalChildren = null, + Object? progressRatio = null, + }) { + return _then( + _$ProgressResponseModelImpl( + clearedChildren: null == clearedChildren + ? _value.clearedChildren + : clearedChildren // ignore: cast_nullable_to_non_nullable + as int, + totalChildren: null == totalChildren + ? _value.totalChildren + : totalChildren // ignore: cast_nullable_to_non_nullable + as int, + progressRatio: null == progressRatio + ? _value.progressRatio + : progressRatio // ignore: cast_nullable_to_non_nullable + as double, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ProgressResponseModelImpl implements _ProgressResponseModel { + const _$ProgressResponseModelImpl({ + required this.clearedChildren, + required this.totalChildren, + this.progressRatio = 0.0, + }); + + factory _$ProgressResponseModelImpl.fromJson(Map json) => + _$$ProgressResponseModelImplFromJson(json); + + @override + final int clearedChildren; + @override + final int totalChildren; + @override + @JsonKey() + final double progressRatio; + + @override + String toString() { + return 'ProgressResponseModel(clearedChildren: $clearedChildren, totalChildren: $totalChildren, progressRatio: $progressRatio)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProgressResponseModelImpl && + (identical(other.clearedChildren, clearedChildren) || + other.clearedChildren == clearedChildren) && + (identical(other.totalChildren, totalChildren) || + other.totalChildren == totalChildren) && + (identical(other.progressRatio, progressRatio) || + other.progressRatio == progressRatio)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, clearedChildren, totalChildren, progressRatio); + + /// Create a copy of ProgressResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ProgressResponseModelImplCopyWith<_$ProgressResponseModelImpl> + get copyWith => + __$$ProgressResponseModelImplCopyWithImpl<_$ProgressResponseModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$ProgressResponseModelImplToJson(this); + } +} + +abstract class _ProgressResponseModel implements ProgressResponseModel { + const factory _ProgressResponseModel({ + required final int clearedChildren, + required final int totalChildren, + final double progressRatio, + }) = _$ProgressResponseModelImpl; + + factory _ProgressResponseModel.fromJson(Map json) = + _$ProgressResponseModelImpl.fromJson; + + @override + int get clearedChildren; + @override + int get totalChildren; + @override + double get progressRatio; + + /// Create a copy of ProgressResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ProgressResponseModelImplCopyWith<_$ProgressResponseModelImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/features/exploration/data/models/planet_response_model.g.dart b/lib/features/exploration/data/models/planet_response_model.g.dart new file mode 100644 index 0000000..cafd618 --- /dev/null +++ b/lib/features/exploration/data/models/planet_response_model.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'planet_response_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$PlanetResponseModelImpl _$$PlanetResponseModelImplFromJson( + Map json, +) => _$PlanetResponseModelImpl( + id: json['id'] as String, + name: json['name'] as String, + nodeType: json['nodeType'] as String, + depth: (json['depth'] as num).toInt(), + icon: json['icon'] as String, + parentId: json['parentId'] as String?, + prerequisiteId: json['prerequisiteId'] as String?, + requiredFuel: (json['requiredFuel'] as num).toInt(), + isUnlocked: json['isUnlocked'] as bool, + isCleared: json['isCleared'] as bool, + sortOrder: (json['sortOrder'] as num).toInt(), + description: json['description'] as String? ?? '', + mapX: (json['mapX'] as num?)?.toDouble() ?? 0.5, + mapY: (json['mapY'] as num?)?.toDouble() ?? 0.0, + unlockedAt: json['unlockedAt'] == null + ? null + : DateTime.parse(json['unlockedAt'] as String), + progress: json['progress'] == null + ? null + : ProgressResponseModel.fromJson( + json['progress'] as Map, + ), +); + +Map _$$PlanetResponseModelImplToJson( + _$PlanetResponseModelImpl instance, +) => { + 'id': instance.id, + 'name': instance.name, + 'nodeType': instance.nodeType, + 'depth': instance.depth, + 'icon': instance.icon, + 'parentId': instance.parentId, + 'prerequisiteId': instance.prerequisiteId, + 'requiredFuel': instance.requiredFuel, + 'isUnlocked': instance.isUnlocked, + 'isCleared': instance.isCleared, + 'sortOrder': instance.sortOrder, + 'description': instance.description, + 'mapX': instance.mapX, + 'mapY': instance.mapY, + 'unlockedAt': instance.unlockedAt?.toIso8601String(), + 'progress': instance.progress, +}; + +_$ProgressResponseModelImpl _$$ProgressResponseModelImplFromJson( + Map json, +) => _$ProgressResponseModelImpl( + clearedChildren: (json['clearedChildren'] as num).toInt(), + totalChildren: (json['totalChildren'] as num).toInt(), + progressRatio: (json['progressRatio'] as num?)?.toDouble() ?? 0.0, +); + +Map _$$ProgressResponseModelImplToJson( + _$ProgressResponseModelImpl instance, +) => { + 'clearedChildren': instance.clearedChildren, + 'totalChildren': instance.totalChildren, + 'progressRatio': instance.progressRatio, +}; diff --git a/test/features/exploration/data/models/planet_response_model_test.dart b/test/features/exploration/data/models/planet_response_model_test.dart new file mode 100644 index 0000000..6087a7f --- /dev/null +++ b/test/features/exploration/data/models/planet_response_model_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/data/models/planet_response_model.dart'; +import 'package:space_study_ship/features/exploration/domain/entities/exploration_node_entity.dart'; + +void main() { + group('PlanetResponseModel', () { + test('fromJson + toEntity — 해금된 행성 (progress 포함)', () { + final json = { + 'id': 'mars', + 'name': '화성', + 'nodeType': 'planet', + 'depth': 2, + 'icon': 'mars', + 'parentId': null, + 'prerequisiteId': 'venus', + 'requiredFuel': 10, + 'isUnlocked': true, + 'isCleared': false, + 'sortOrder': 3, + 'description': '붉은 행성', + 'mapX': 0.25, + 'mapY': 0.44, + 'unlockedAt': '2026-04-01T00:00:00Z', + 'progress': { + 'clearedChildren': 3, + 'totalChildren': 5, + 'progressRatio': 0.6, + }, + }; + final model = PlanetResponseModel.fromJson(json); + final entity = model.toEntity(); + + expect(entity.id, 'mars'); + expect(entity.nodeType, ExplorationNodeType.planet); + expect(entity.requiredFuel, 10); + expect(entity.isUnlocked, true); + expect(entity.unlockedAt, DateTime.utc(2026, 4, 1)); + + final progress = model.progressEntity(); + expect(progress.clearedChildren, 3); + expect(progress.totalChildren, 5); + expect(progress.progressRatio, closeTo(0.6, 0.001)); + }); + + test('fromJson — 잠긴 행성 (unlockedAt/progress 없음)', () { + final json = { + 'id': 'neptune', + 'name': '해왕성', + 'nodeType': 'planet', + 'depth': 2, + 'icon': 'neptune', + 'requiredFuel': 60, + 'isUnlocked': false, + 'isCleared': false, + 'sortOrder': 7, + }; + final model = PlanetResponseModel.fromJson(json); + final entity = model.toEntity(); + + expect(entity.unlockedAt, isNull); + expect(entity.isUnlocked, false); + expect(model.progressEntity().totalChildren, 0); + }); + }); +} From 092c86f4c3e23b08728996c9543e4edaeb3067b2 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 19:00:12 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat=20:=20RegionResponseModel=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/models/region_response_model.dart | 50 ++ .../models/region_response_model.freezed.dart | 470 ++++++++++++++++++ .../data/models/region_response_model.g.dart | 47 ++ .../models/region_response_model_test.dart | 28 ++ 4 files changed, 595 insertions(+) create mode 100644 lib/features/exploration/data/models/region_response_model.dart create mode 100644 lib/features/exploration/data/models/region_response_model.freezed.dart create mode 100644 lib/features/exploration/data/models/region_response_model.g.dart create mode 100644 test/features/exploration/data/models/region_response_model_test.dart diff --git a/lib/features/exploration/data/models/region_response_model.dart b/lib/features/exploration/data/models/region_response_model.dart new file mode 100644 index 0000000..645f926 --- /dev/null +++ b/lib/features/exploration/data/models/region_response_model.dart @@ -0,0 +1,50 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/exploration_node_entity.dart'; +import 'planet_response_model.dart' show nodeTypeFromString; + +part 'region_response_model.freezed.dart'; +part 'region_response_model.g.dart'; + +/// GET /api/explorations/planets/{planetId}/regions 응답 항목 DTO +@freezed +class RegionResponseModel with _$RegionResponseModel { + const RegionResponseModel._(); + + const factory RegionResponseModel({ + required String id, + required String name, + required String nodeType, + required int depth, + required String icon, + String? parentId, + required int requiredFuel, + required bool isUnlocked, + required bool isCleared, + required int sortOrder, + @Default('') String description, + @Default(0.5) double mapX, + @Default(0.0) double mapY, + DateTime? unlockedAt, + }) = _RegionResponseModel; + + factory RegionResponseModel.fromJson(Map json) => + _$RegionResponseModelFromJson(json); + + ExplorationNodeEntity toEntity() => ExplorationNodeEntity( + id: id, + name: name, + nodeType: nodeTypeFromString(nodeType), + depth: depth, + icon: icon, + parentId: parentId, + requiredFuel: requiredFuel, + isUnlocked: isUnlocked, + isCleared: isCleared, + sortOrder: sortOrder, + description: description, + mapX: mapX, + mapY: mapY, + unlockedAt: unlockedAt, + ); +} diff --git a/lib/features/exploration/data/models/region_response_model.freezed.dart b/lib/features/exploration/data/models/region_response_model.freezed.dart new file mode 100644 index 0000000..de81f4f --- /dev/null +++ b/lib/features/exploration/data/models/region_response_model.freezed.dart @@ -0,0 +1,470 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'region_response_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +RegionResponseModel _$RegionResponseModelFromJson(Map json) { + return _RegionResponseModel.fromJson(json); +} + +/// @nodoc +mixin _$RegionResponseModel { + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get nodeType => throw _privateConstructorUsedError; + int get depth => throw _privateConstructorUsedError; + String get icon => throw _privateConstructorUsedError; + String? get parentId => throw _privateConstructorUsedError; + int get requiredFuel => throw _privateConstructorUsedError; + bool get isUnlocked => throw _privateConstructorUsedError; + bool get isCleared => throw _privateConstructorUsedError; + int get sortOrder => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + double get mapX => throw _privateConstructorUsedError; + double get mapY => throw _privateConstructorUsedError; + DateTime? get unlockedAt => throw _privateConstructorUsedError; + + /// Serializes this RegionResponseModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of RegionResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $RegionResponseModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RegionResponseModelCopyWith<$Res> { + factory $RegionResponseModelCopyWith( + RegionResponseModel value, + $Res Function(RegionResponseModel) then, + ) = _$RegionResponseModelCopyWithImpl<$Res, RegionResponseModel>; + @useResult + $Res call({ + String id, + String name, + String nodeType, + int depth, + String icon, + String? parentId, + int requiredFuel, + bool isUnlocked, + bool isCleared, + int sortOrder, + String description, + double mapX, + double mapY, + DateTime? unlockedAt, + }); +} + +/// @nodoc +class _$RegionResponseModelCopyWithImpl<$Res, $Val extends RegionResponseModel> + implements $RegionResponseModelCopyWith<$Res> { + _$RegionResponseModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of RegionResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? nodeType = null, + Object? depth = null, + Object? icon = null, + Object? parentId = freezed, + Object? requiredFuel = null, + Object? isUnlocked = null, + Object? isCleared = null, + Object? sortOrder = null, + Object? description = null, + Object? mapX = null, + Object? mapY = null, + Object? unlockedAt = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + nodeType: null == nodeType + ? _value.nodeType + : nodeType // ignore: cast_nullable_to_non_nullable + as String, + depth: null == depth + ? _value.depth + : depth // ignore: cast_nullable_to_non_nullable + as int, + icon: null == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String, + parentId: freezed == parentId + ? _value.parentId + : parentId // ignore: cast_nullable_to_non_nullable + as String?, + requiredFuel: null == requiredFuel + ? _value.requiredFuel + : requiredFuel // ignore: cast_nullable_to_non_nullable + as int, + isUnlocked: null == isUnlocked + ? _value.isUnlocked + : isUnlocked // ignore: cast_nullable_to_non_nullable + as bool, + isCleared: null == isCleared + ? _value.isCleared + : isCleared // ignore: cast_nullable_to_non_nullable + as bool, + sortOrder: null == sortOrder + ? _value.sortOrder + : sortOrder // ignore: cast_nullable_to_non_nullable + as int, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + mapX: null == mapX + ? _value.mapX + : mapX // ignore: cast_nullable_to_non_nullable + as double, + mapY: null == mapY + ? _value.mapY + : mapY // ignore: cast_nullable_to_non_nullable + as double, + unlockedAt: freezed == unlockedAt + ? _value.unlockedAt + : unlockedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$RegionResponseModelImplCopyWith<$Res> + implements $RegionResponseModelCopyWith<$Res> { + factory _$$RegionResponseModelImplCopyWith( + _$RegionResponseModelImpl value, + $Res Function(_$RegionResponseModelImpl) then, + ) = __$$RegionResponseModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String id, + String name, + String nodeType, + int depth, + String icon, + String? parentId, + int requiredFuel, + bool isUnlocked, + bool isCleared, + int sortOrder, + String description, + double mapX, + double mapY, + DateTime? unlockedAt, + }); +} + +/// @nodoc +class __$$RegionResponseModelImplCopyWithImpl<$Res> + extends _$RegionResponseModelCopyWithImpl<$Res, _$RegionResponseModelImpl> + implements _$$RegionResponseModelImplCopyWith<$Res> { + __$$RegionResponseModelImplCopyWithImpl( + _$RegionResponseModelImpl _value, + $Res Function(_$RegionResponseModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of RegionResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? nodeType = null, + Object? depth = null, + Object? icon = null, + Object? parentId = freezed, + Object? requiredFuel = null, + Object? isUnlocked = null, + Object? isCleared = null, + Object? sortOrder = null, + Object? description = null, + Object? mapX = null, + Object? mapY = null, + Object? unlockedAt = freezed, + }) { + return _then( + _$RegionResponseModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + nodeType: null == nodeType + ? _value.nodeType + : nodeType // ignore: cast_nullable_to_non_nullable + as String, + depth: null == depth + ? _value.depth + : depth // ignore: cast_nullable_to_non_nullable + as int, + icon: null == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String, + parentId: freezed == parentId + ? _value.parentId + : parentId // ignore: cast_nullable_to_non_nullable + as String?, + requiredFuel: null == requiredFuel + ? _value.requiredFuel + : requiredFuel // ignore: cast_nullable_to_non_nullable + as int, + isUnlocked: null == isUnlocked + ? _value.isUnlocked + : isUnlocked // ignore: cast_nullable_to_non_nullable + as bool, + isCleared: null == isCleared + ? _value.isCleared + : isCleared // ignore: cast_nullable_to_non_nullable + as bool, + sortOrder: null == sortOrder + ? _value.sortOrder + : sortOrder // ignore: cast_nullable_to_non_nullable + as int, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + mapX: null == mapX + ? _value.mapX + : mapX // ignore: cast_nullable_to_non_nullable + as double, + mapY: null == mapY + ? _value.mapY + : mapY // ignore: cast_nullable_to_non_nullable + as double, + unlockedAt: freezed == unlockedAt + ? _value.unlockedAt + : unlockedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$RegionResponseModelImpl extends _RegionResponseModel { + const _$RegionResponseModelImpl({ + required this.id, + required this.name, + required this.nodeType, + required this.depth, + required this.icon, + this.parentId, + required this.requiredFuel, + required this.isUnlocked, + required this.isCleared, + required this.sortOrder, + this.description = '', + this.mapX = 0.5, + this.mapY = 0.0, + this.unlockedAt, + }) : super._(); + + factory _$RegionResponseModelImpl.fromJson(Map json) => + _$$RegionResponseModelImplFromJson(json); + + @override + final String id; + @override + final String name; + @override + final String nodeType; + @override + final int depth; + @override + final String icon; + @override + final String? parentId; + @override + final int requiredFuel; + @override + final bool isUnlocked; + @override + final bool isCleared; + @override + final int sortOrder; + @override + @JsonKey() + final String description; + @override + @JsonKey() + final double mapX; + @override + @JsonKey() + final double mapY; + @override + final DateTime? unlockedAt; + + @override + String toString() { + return 'RegionResponseModel(id: $id, name: $name, nodeType: $nodeType, depth: $depth, icon: $icon, parentId: $parentId, requiredFuel: $requiredFuel, isUnlocked: $isUnlocked, isCleared: $isCleared, sortOrder: $sortOrder, description: $description, mapX: $mapX, mapY: $mapY, unlockedAt: $unlockedAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RegionResponseModelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.nodeType, nodeType) || + other.nodeType == nodeType) && + (identical(other.depth, depth) || other.depth == depth) && + (identical(other.icon, icon) || other.icon == icon) && + (identical(other.parentId, parentId) || + other.parentId == parentId) && + (identical(other.requiredFuel, requiredFuel) || + other.requiredFuel == requiredFuel) && + (identical(other.isUnlocked, isUnlocked) || + other.isUnlocked == isUnlocked) && + (identical(other.isCleared, isCleared) || + other.isCleared == isCleared) && + (identical(other.sortOrder, sortOrder) || + other.sortOrder == sortOrder) && + (identical(other.description, description) || + other.description == description) && + (identical(other.mapX, mapX) || other.mapX == mapX) && + (identical(other.mapY, mapY) || other.mapY == mapY) && + (identical(other.unlockedAt, unlockedAt) || + other.unlockedAt == unlockedAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + name, + nodeType, + depth, + icon, + parentId, + requiredFuel, + isUnlocked, + isCleared, + sortOrder, + description, + mapX, + mapY, + unlockedAt, + ); + + /// Create a copy of RegionResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$RegionResponseModelImplCopyWith<_$RegionResponseModelImpl> get copyWith => + __$$RegionResponseModelImplCopyWithImpl<_$RegionResponseModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$RegionResponseModelImplToJson(this); + } +} + +abstract class _RegionResponseModel extends RegionResponseModel { + const factory _RegionResponseModel({ + required final String id, + required final String name, + required final String nodeType, + required final int depth, + required final String icon, + final String? parentId, + required final int requiredFuel, + required final bool isUnlocked, + required final bool isCleared, + required final int sortOrder, + final String description, + final double mapX, + final double mapY, + final DateTime? unlockedAt, + }) = _$RegionResponseModelImpl; + const _RegionResponseModel._() : super._(); + + factory _RegionResponseModel.fromJson(Map json) = + _$RegionResponseModelImpl.fromJson; + + @override + String get id; + @override + String get name; + @override + String get nodeType; + @override + int get depth; + @override + String get icon; + @override + String? get parentId; + @override + int get requiredFuel; + @override + bool get isUnlocked; + @override + bool get isCleared; + @override + int get sortOrder; + @override + String get description; + @override + double get mapX; + @override + double get mapY; + @override + DateTime? get unlockedAt; + + /// Create a copy of RegionResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$RegionResponseModelImplCopyWith<_$RegionResponseModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/exploration/data/models/region_response_model.g.dart b/lib/features/exploration/data/models/region_response_model.g.dart new file mode 100644 index 0000000..5511bd0 --- /dev/null +++ b/lib/features/exploration/data/models/region_response_model.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'region_response_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$RegionResponseModelImpl _$$RegionResponseModelImplFromJson( + Map json, +) => _$RegionResponseModelImpl( + id: json['id'] as String, + name: json['name'] as String, + nodeType: json['nodeType'] as String, + depth: (json['depth'] as num).toInt(), + icon: json['icon'] as String, + parentId: json['parentId'] as String?, + requiredFuel: (json['requiredFuel'] as num).toInt(), + isUnlocked: json['isUnlocked'] as bool, + isCleared: json['isCleared'] as bool, + sortOrder: (json['sortOrder'] as num).toInt(), + description: json['description'] as String? ?? '', + mapX: (json['mapX'] as num?)?.toDouble() ?? 0.5, + mapY: (json['mapY'] as num?)?.toDouble() ?? 0.0, + unlockedAt: json['unlockedAt'] == null + ? null + : DateTime.parse(json['unlockedAt'] as String), +); + +Map _$$RegionResponseModelImplToJson( + _$RegionResponseModelImpl instance, +) => { + 'id': instance.id, + 'name': instance.name, + 'nodeType': instance.nodeType, + 'depth': instance.depth, + 'icon': instance.icon, + 'parentId': instance.parentId, + 'requiredFuel': instance.requiredFuel, + 'isUnlocked': instance.isUnlocked, + 'isCleared': instance.isCleared, + 'sortOrder': instance.sortOrder, + 'description': instance.description, + 'mapX': instance.mapX, + 'mapY': instance.mapY, + 'unlockedAt': instance.unlockedAt?.toIso8601String(), +}; diff --git a/test/features/exploration/data/models/region_response_model_test.dart b/test/features/exploration/data/models/region_response_model_test.dart new file mode 100644 index 0000000..311df5e --- /dev/null +++ b/test/features/exploration/data/models/region_response_model_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/data/models/region_response_model.dart'; +import 'package:space_study_ship/features/exploration/domain/entities/exploration_node_entity.dart'; + +void main() { + test('RegionResponseModel — fromJson + toEntity', () { + final json = { + 'id': 'japan', + 'name': '일본', + 'nodeType': 'region', + 'depth': 3, + 'icon': 'JP', + 'parentId': 'earth', + 'requiredFuel': 5, + 'isUnlocked': true, + 'isCleared': true, + 'sortOrder': 1, + 'description': '이웃 섬나라', + 'unlockedAt': '2026-04-05T15:30:00Z', + }; + final entity = RegionResponseModel.fromJson(json).toEntity(); + expect(entity.id, 'japan'); + expect(entity.nodeType, ExplorationNodeType.region); + expect(entity.parentId, 'earth'); + expect(entity.isCleared, true); + expect(entity.unlockedAt, DateTime.utc(2026, 4, 5, 15, 30)); + }); +} From 6a51222a59dae932d9def99eb4a6909dff6d52d7 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 19:02:26 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat=20:=20=ED=96=89=EC=84=B1/=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=ED=95=B4=EA=B8=88=20=EC=9D=91=EB=8B=B5=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/models/unlock_response_models.dart | 60 ++ .../unlock_response_models.freezed.dart | 760 ++++++++++++++++++ .../data/models/unlock_response_models.g.dart | 67 ++ .../models/unlock_response_models_test.dart | 37 + 4 files changed, 924 insertions(+) create mode 100644 lib/features/exploration/data/models/unlock_response_models.dart create mode 100644 lib/features/exploration/data/models/unlock_response_models.freezed.dart create mode 100644 lib/features/exploration/data/models/unlock_response_models.g.dart create mode 100644 test/features/exploration/data/models/unlock_response_models_test.dart diff --git a/lib/features/exploration/data/models/unlock_response_models.dart b/lib/features/exploration/data/models/unlock_response_models.dart new file mode 100644 index 0000000..4cc847c --- /dev/null +++ b/lib/features/exploration/data/models/unlock_response_models.dart @@ -0,0 +1,60 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/unlock_result_entities.dart'; + +part 'unlock_response_models.freezed.dart'; +part 'unlock_response_models.g.dart'; + +/// 해금된 노드 요약 (중첩) +@freezed +class UnlockedNodeModel with _$UnlockedNodeModel { + const factory UnlockedNodeModel({ + required String id, + required String name, + required bool isUnlocked, + required bool isCleared, + DateTime? unlockedAt, + }) = _UnlockedNodeModel; + + factory UnlockedNodeModel.fromJson(Map json) => + _$UnlockedNodeModelFromJson(json); +} + +/// POST /api/explorations/regions/{regionId}/unlock 응답 +@freezed +class RegionUnlockResponseModel with _$RegionUnlockResponseModel { + const RegionUnlockResponseModel._(); + + const factory RegionUnlockResponseModel({ + UnlockedNodeModel? region, + required int fuelConsumed, + required int currentFuel, + required bool planetCleared, + }) = _RegionUnlockResponseModel; + + factory RegionUnlockResponseModel.fromJson(Map json) => + _$RegionUnlockResponseModelFromJson(json); + + RegionUnlockResultEntity toEntity() => RegionUnlockResultEntity( + planetCleared: planetCleared, + currentFuel: currentFuel, + ); +} + +/// POST /api/explorations/planets/{planetId}/unlock 응답 +@freezed +class PlanetUnlockResponseModel with _$PlanetUnlockResponseModel { + const PlanetUnlockResponseModel._(); + + const factory PlanetUnlockResponseModel({ + UnlockedNodeModel? planet, + required int fuelConsumed, + required int currentFuel, + }) = _PlanetUnlockResponseModel; + + factory PlanetUnlockResponseModel.fromJson(Map json) => + _$PlanetUnlockResponseModelFromJson(json); + + PlanetUnlockResultEntity toEntity() => + PlanetUnlockResultEntity(currentFuel: currentFuel); +} diff --git a/lib/features/exploration/data/models/unlock_response_models.freezed.dart b/lib/features/exploration/data/models/unlock_response_models.freezed.dart new file mode 100644 index 0000000..754f486 --- /dev/null +++ b/lib/features/exploration/data/models/unlock_response_models.freezed.dart @@ -0,0 +1,760 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'unlock_response_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +UnlockedNodeModel _$UnlockedNodeModelFromJson(Map json) { + return _UnlockedNodeModel.fromJson(json); +} + +/// @nodoc +mixin _$UnlockedNodeModel { + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + bool get isUnlocked => throw _privateConstructorUsedError; + bool get isCleared => throw _privateConstructorUsedError; + DateTime? get unlockedAt => throw _privateConstructorUsedError; + + /// Serializes this UnlockedNodeModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of UnlockedNodeModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $UnlockedNodeModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $UnlockedNodeModelCopyWith<$Res> { + factory $UnlockedNodeModelCopyWith( + UnlockedNodeModel value, + $Res Function(UnlockedNodeModel) then, + ) = _$UnlockedNodeModelCopyWithImpl<$Res, UnlockedNodeModel>; + @useResult + $Res call({ + String id, + String name, + bool isUnlocked, + bool isCleared, + DateTime? unlockedAt, + }); +} + +/// @nodoc +class _$UnlockedNodeModelCopyWithImpl<$Res, $Val extends UnlockedNodeModel> + implements $UnlockedNodeModelCopyWith<$Res> { + _$UnlockedNodeModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of UnlockedNodeModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? isUnlocked = null, + Object? isCleared = null, + Object? unlockedAt = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + isUnlocked: null == isUnlocked + ? _value.isUnlocked + : isUnlocked // ignore: cast_nullable_to_non_nullable + as bool, + isCleared: null == isCleared + ? _value.isCleared + : isCleared // ignore: cast_nullable_to_non_nullable + as bool, + unlockedAt: freezed == unlockedAt + ? _value.unlockedAt + : unlockedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$UnlockedNodeModelImplCopyWith<$Res> + implements $UnlockedNodeModelCopyWith<$Res> { + factory _$$UnlockedNodeModelImplCopyWith( + _$UnlockedNodeModelImpl value, + $Res Function(_$UnlockedNodeModelImpl) then, + ) = __$$UnlockedNodeModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String id, + String name, + bool isUnlocked, + bool isCleared, + DateTime? unlockedAt, + }); +} + +/// @nodoc +class __$$UnlockedNodeModelImplCopyWithImpl<$Res> + extends _$UnlockedNodeModelCopyWithImpl<$Res, _$UnlockedNodeModelImpl> + implements _$$UnlockedNodeModelImplCopyWith<$Res> { + __$$UnlockedNodeModelImplCopyWithImpl( + _$UnlockedNodeModelImpl _value, + $Res Function(_$UnlockedNodeModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of UnlockedNodeModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? isUnlocked = null, + Object? isCleared = null, + Object? unlockedAt = freezed, + }) { + return _then( + _$UnlockedNodeModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + isUnlocked: null == isUnlocked + ? _value.isUnlocked + : isUnlocked // ignore: cast_nullable_to_non_nullable + as bool, + isCleared: null == isCleared + ? _value.isCleared + : isCleared // ignore: cast_nullable_to_non_nullable + as bool, + unlockedAt: freezed == unlockedAt + ? _value.unlockedAt + : unlockedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$UnlockedNodeModelImpl implements _UnlockedNodeModel { + const _$UnlockedNodeModelImpl({ + required this.id, + required this.name, + required this.isUnlocked, + required this.isCleared, + this.unlockedAt, + }); + + factory _$UnlockedNodeModelImpl.fromJson(Map json) => + _$$UnlockedNodeModelImplFromJson(json); + + @override + final String id; + @override + final String name; + @override + final bool isUnlocked; + @override + final bool isCleared; + @override + final DateTime? unlockedAt; + + @override + String toString() { + return 'UnlockedNodeModel(id: $id, name: $name, isUnlocked: $isUnlocked, isCleared: $isCleared, unlockedAt: $unlockedAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$UnlockedNodeModelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.isUnlocked, isUnlocked) || + other.isUnlocked == isUnlocked) && + (identical(other.isCleared, isCleared) || + other.isCleared == isCleared) && + (identical(other.unlockedAt, unlockedAt) || + other.unlockedAt == unlockedAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, id, name, isUnlocked, isCleared, unlockedAt); + + /// Create a copy of UnlockedNodeModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$UnlockedNodeModelImplCopyWith<_$UnlockedNodeModelImpl> get copyWith => + __$$UnlockedNodeModelImplCopyWithImpl<_$UnlockedNodeModelImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$UnlockedNodeModelImplToJson(this); + } +} + +abstract class _UnlockedNodeModel implements UnlockedNodeModel { + const factory _UnlockedNodeModel({ + required final String id, + required final String name, + required final bool isUnlocked, + required final bool isCleared, + final DateTime? unlockedAt, + }) = _$UnlockedNodeModelImpl; + + factory _UnlockedNodeModel.fromJson(Map json) = + _$UnlockedNodeModelImpl.fromJson; + + @override + String get id; + @override + String get name; + @override + bool get isUnlocked; + @override + bool get isCleared; + @override + DateTime? get unlockedAt; + + /// Create a copy of UnlockedNodeModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$UnlockedNodeModelImplCopyWith<_$UnlockedNodeModelImpl> get copyWith => + throw _privateConstructorUsedError; +} + +RegionUnlockResponseModel _$RegionUnlockResponseModelFromJson( + Map json, +) { + return _RegionUnlockResponseModel.fromJson(json); +} + +/// @nodoc +mixin _$RegionUnlockResponseModel { + UnlockedNodeModel? get region => throw _privateConstructorUsedError; + int get fuelConsumed => throw _privateConstructorUsedError; + int get currentFuel => throw _privateConstructorUsedError; + bool get planetCleared => throw _privateConstructorUsedError; + + /// Serializes this RegionUnlockResponseModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of RegionUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $RegionUnlockResponseModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RegionUnlockResponseModelCopyWith<$Res> { + factory $RegionUnlockResponseModelCopyWith( + RegionUnlockResponseModel value, + $Res Function(RegionUnlockResponseModel) then, + ) = _$RegionUnlockResponseModelCopyWithImpl<$Res, RegionUnlockResponseModel>; + @useResult + $Res call({ + UnlockedNodeModel? region, + int fuelConsumed, + int currentFuel, + bool planetCleared, + }); + + $UnlockedNodeModelCopyWith<$Res>? get region; +} + +/// @nodoc +class _$RegionUnlockResponseModelCopyWithImpl< + $Res, + $Val extends RegionUnlockResponseModel +> + implements $RegionUnlockResponseModelCopyWith<$Res> { + _$RegionUnlockResponseModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of RegionUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? region = freezed, + Object? fuelConsumed = null, + Object? currentFuel = null, + Object? planetCleared = null, + }) { + return _then( + _value.copyWith( + region: freezed == region + ? _value.region + : region // ignore: cast_nullable_to_non_nullable + as UnlockedNodeModel?, + fuelConsumed: null == fuelConsumed + ? _value.fuelConsumed + : fuelConsumed // ignore: cast_nullable_to_non_nullable + as int, + currentFuel: null == currentFuel + ? _value.currentFuel + : currentFuel // ignore: cast_nullable_to_non_nullable + as int, + planetCleared: null == planetCleared + ? _value.planetCleared + : planetCleared // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } + + /// Create a copy of RegionUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UnlockedNodeModelCopyWith<$Res>? get region { + if (_value.region == null) { + return null; + } + + return $UnlockedNodeModelCopyWith<$Res>(_value.region!, (value) { + return _then(_value.copyWith(region: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$RegionUnlockResponseModelImplCopyWith<$Res> + implements $RegionUnlockResponseModelCopyWith<$Res> { + factory _$$RegionUnlockResponseModelImplCopyWith( + _$RegionUnlockResponseModelImpl value, + $Res Function(_$RegionUnlockResponseModelImpl) then, + ) = __$$RegionUnlockResponseModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + UnlockedNodeModel? region, + int fuelConsumed, + int currentFuel, + bool planetCleared, + }); + + @override + $UnlockedNodeModelCopyWith<$Res>? get region; +} + +/// @nodoc +class __$$RegionUnlockResponseModelImplCopyWithImpl<$Res> + extends + _$RegionUnlockResponseModelCopyWithImpl< + $Res, + _$RegionUnlockResponseModelImpl + > + implements _$$RegionUnlockResponseModelImplCopyWith<$Res> { + __$$RegionUnlockResponseModelImplCopyWithImpl( + _$RegionUnlockResponseModelImpl _value, + $Res Function(_$RegionUnlockResponseModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of RegionUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? region = freezed, + Object? fuelConsumed = null, + Object? currentFuel = null, + Object? planetCleared = null, + }) { + return _then( + _$RegionUnlockResponseModelImpl( + region: freezed == region + ? _value.region + : region // ignore: cast_nullable_to_non_nullable + as UnlockedNodeModel?, + fuelConsumed: null == fuelConsumed + ? _value.fuelConsumed + : fuelConsumed // ignore: cast_nullable_to_non_nullable + as int, + currentFuel: null == currentFuel + ? _value.currentFuel + : currentFuel // ignore: cast_nullable_to_non_nullable + as int, + planetCleared: null == planetCleared + ? _value.planetCleared + : planetCleared // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$RegionUnlockResponseModelImpl extends _RegionUnlockResponseModel { + const _$RegionUnlockResponseModelImpl({ + this.region, + required this.fuelConsumed, + required this.currentFuel, + required this.planetCleared, + }) : super._(); + + factory _$RegionUnlockResponseModelImpl.fromJson(Map json) => + _$$RegionUnlockResponseModelImplFromJson(json); + + @override + final UnlockedNodeModel? region; + @override + final int fuelConsumed; + @override + final int currentFuel; + @override + final bool planetCleared; + + @override + String toString() { + return 'RegionUnlockResponseModel(region: $region, fuelConsumed: $fuelConsumed, currentFuel: $currentFuel, planetCleared: $planetCleared)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RegionUnlockResponseModelImpl && + (identical(other.region, region) || other.region == region) && + (identical(other.fuelConsumed, fuelConsumed) || + other.fuelConsumed == fuelConsumed) && + (identical(other.currentFuel, currentFuel) || + other.currentFuel == currentFuel) && + (identical(other.planetCleared, planetCleared) || + other.planetCleared == planetCleared)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + region, + fuelConsumed, + currentFuel, + planetCleared, + ); + + /// Create a copy of RegionUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$RegionUnlockResponseModelImplCopyWith<_$RegionUnlockResponseModelImpl> + get copyWith => + __$$RegionUnlockResponseModelImplCopyWithImpl< + _$RegionUnlockResponseModelImpl + >(this, _$identity); + + @override + Map toJson() { + return _$$RegionUnlockResponseModelImplToJson(this); + } +} + +abstract class _RegionUnlockResponseModel extends RegionUnlockResponseModel { + const factory _RegionUnlockResponseModel({ + final UnlockedNodeModel? region, + required final int fuelConsumed, + required final int currentFuel, + required final bool planetCleared, + }) = _$RegionUnlockResponseModelImpl; + const _RegionUnlockResponseModel._() : super._(); + + factory _RegionUnlockResponseModel.fromJson(Map json) = + _$RegionUnlockResponseModelImpl.fromJson; + + @override + UnlockedNodeModel? get region; + @override + int get fuelConsumed; + @override + int get currentFuel; + @override + bool get planetCleared; + + /// Create a copy of RegionUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$RegionUnlockResponseModelImplCopyWith<_$RegionUnlockResponseModelImpl> + get copyWith => throw _privateConstructorUsedError; +} + +PlanetUnlockResponseModel _$PlanetUnlockResponseModelFromJson( + Map json, +) { + return _PlanetUnlockResponseModel.fromJson(json); +} + +/// @nodoc +mixin _$PlanetUnlockResponseModel { + UnlockedNodeModel? get planet => throw _privateConstructorUsedError; + int get fuelConsumed => throw _privateConstructorUsedError; + int get currentFuel => throw _privateConstructorUsedError; + + /// Serializes this PlanetUnlockResponseModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of PlanetUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $PlanetUnlockResponseModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlanetUnlockResponseModelCopyWith<$Res> { + factory $PlanetUnlockResponseModelCopyWith( + PlanetUnlockResponseModel value, + $Res Function(PlanetUnlockResponseModel) then, + ) = _$PlanetUnlockResponseModelCopyWithImpl<$Res, PlanetUnlockResponseModel>; + @useResult + $Res call({UnlockedNodeModel? planet, int fuelConsumed, int currentFuel}); + + $UnlockedNodeModelCopyWith<$Res>? get planet; +} + +/// @nodoc +class _$PlanetUnlockResponseModelCopyWithImpl< + $Res, + $Val extends PlanetUnlockResponseModel +> + implements $PlanetUnlockResponseModelCopyWith<$Res> { + _$PlanetUnlockResponseModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of PlanetUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? planet = freezed, + Object? fuelConsumed = null, + Object? currentFuel = null, + }) { + return _then( + _value.copyWith( + planet: freezed == planet + ? _value.planet + : planet // ignore: cast_nullable_to_non_nullable + as UnlockedNodeModel?, + fuelConsumed: null == fuelConsumed + ? _value.fuelConsumed + : fuelConsumed // ignore: cast_nullable_to_non_nullable + as int, + currentFuel: null == currentFuel + ? _value.currentFuel + : currentFuel // ignore: cast_nullable_to_non_nullable + as int, + ) + as $Val, + ); + } + + /// Create a copy of PlanetUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $UnlockedNodeModelCopyWith<$Res>? get planet { + if (_value.planet == null) { + return null; + } + + return $UnlockedNodeModelCopyWith<$Res>(_value.planet!, (value) { + return _then(_value.copyWith(planet: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$PlanetUnlockResponseModelImplCopyWith<$Res> + implements $PlanetUnlockResponseModelCopyWith<$Res> { + factory _$$PlanetUnlockResponseModelImplCopyWith( + _$PlanetUnlockResponseModelImpl value, + $Res Function(_$PlanetUnlockResponseModelImpl) then, + ) = __$$PlanetUnlockResponseModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({UnlockedNodeModel? planet, int fuelConsumed, int currentFuel}); + + @override + $UnlockedNodeModelCopyWith<$Res>? get planet; +} + +/// @nodoc +class __$$PlanetUnlockResponseModelImplCopyWithImpl<$Res> + extends + _$PlanetUnlockResponseModelCopyWithImpl< + $Res, + _$PlanetUnlockResponseModelImpl + > + implements _$$PlanetUnlockResponseModelImplCopyWith<$Res> { + __$$PlanetUnlockResponseModelImplCopyWithImpl( + _$PlanetUnlockResponseModelImpl _value, + $Res Function(_$PlanetUnlockResponseModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of PlanetUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? planet = freezed, + Object? fuelConsumed = null, + Object? currentFuel = null, + }) { + return _then( + _$PlanetUnlockResponseModelImpl( + planet: freezed == planet + ? _value.planet + : planet // ignore: cast_nullable_to_non_nullable + as UnlockedNodeModel?, + fuelConsumed: null == fuelConsumed + ? _value.fuelConsumed + : fuelConsumed // ignore: cast_nullable_to_non_nullable + as int, + currentFuel: null == currentFuel + ? _value.currentFuel + : currentFuel // ignore: cast_nullable_to_non_nullable + as int, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlanetUnlockResponseModelImpl extends _PlanetUnlockResponseModel { + const _$PlanetUnlockResponseModelImpl({ + this.planet, + required this.fuelConsumed, + required this.currentFuel, + }) : super._(); + + factory _$PlanetUnlockResponseModelImpl.fromJson(Map json) => + _$$PlanetUnlockResponseModelImplFromJson(json); + + @override + final UnlockedNodeModel? planet; + @override + final int fuelConsumed; + @override + final int currentFuel; + + @override + String toString() { + return 'PlanetUnlockResponseModel(planet: $planet, fuelConsumed: $fuelConsumed, currentFuel: $currentFuel)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlanetUnlockResponseModelImpl && + (identical(other.planet, planet) || other.planet == planet) && + (identical(other.fuelConsumed, fuelConsumed) || + other.fuelConsumed == fuelConsumed) && + (identical(other.currentFuel, currentFuel) || + other.currentFuel == currentFuel)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, planet, fuelConsumed, currentFuel); + + /// Create a copy of PlanetUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PlanetUnlockResponseModelImplCopyWith<_$PlanetUnlockResponseModelImpl> + get copyWith => + __$$PlanetUnlockResponseModelImplCopyWithImpl< + _$PlanetUnlockResponseModelImpl + >(this, _$identity); + + @override + Map toJson() { + return _$$PlanetUnlockResponseModelImplToJson(this); + } +} + +abstract class _PlanetUnlockResponseModel extends PlanetUnlockResponseModel { + const factory _PlanetUnlockResponseModel({ + final UnlockedNodeModel? planet, + required final int fuelConsumed, + required final int currentFuel, + }) = _$PlanetUnlockResponseModelImpl; + const _PlanetUnlockResponseModel._() : super._(); + + factory _PlanetUnlockResponseModel.fromJson(Map json) = + _$PlanetUnlockResponseModelImpl.fromJson; + + @override + UnlockedNodeModel? get planet; + @override + int get fuelConsumed; + @override + int get currentFuel; + + /// Create a copy of PlanetUnlockResponseModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PlanetUnlockResponseModelImplCopyWith<_$PlanetUnlockResponseModelImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/features/exploration/data/models/unlock_response_models.g.dart b/lib/features/exploration/data/models/unlock_response_models.g.dart new file mode 100644 index 0000000..73611ba --- /dev/null +++ b/lib/features/exploration/data/models/unlock_response_models.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'unlock_response_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$UnlockedNodeModelImpl _$$UnlockedNodeModelImplFromJson( + Map json, +) => _$UnlockedNodeModelImpl( + id: json['id'] as String, + name: json['name'] as String, + isUnlocked: json['isUnlocked'] as bool, + isCleared: json['isCleared'] as bool, + unlockedAt: json['unlockedAt'] == null + ? null + : DateTime.parse(json['unlockedAt'] as String), +); + +Map _$$UnlockedNodeModelImplToJson( + _$UnlockedNodeModelImpl instance, +) => { + 'id': instance.id, + 'name': instance.name, + 'isUnlocked': instance.isUnlocked, + 'isCleared': instance.isCleared, + 'unlockedAt': instance.unlockedAt?.toIso8601String(), +}; + +_$RegionUnlockResponseModelImpl _$$RegionUnlockResponseModelImplFromJson( + Map json, +) => _$RegionUnlockResponseModelImpl( + region: json['region'] == null + ? null + : UnlockedNodeModel.fromJson(json['region'] as Map), + fuelConsumed: (json['fuelConsumed'] as num).toInt(), + currentFuel: (json['currentFuel'] as num).toInt(), + planetCleared: json['planetCleared'] as bool, +); + +Map _$$RegionUnlockResponseModelImplToJson( + _$RegionUnlockResponseModelImpl instance, +) => { + 'region': instance.region, + 'fuelConsumed': instance.fuelConsumed, + 'currentFuel': instance.currentFuel, + 'planetCleared': instance.planetCleared, +}; + +_$PlanetUnlockResponseModelImpl _$$PlanetUnlockResponseModelImplFromJson( + Map json, +) => _$PlanetUnlockResponseModelImpl( + planet: json['planet'] == null + ? null + : UnlockedNodeModel.fromJson(json['planet'] as Map), + fuelConsumed: (json['fuelConsumed'] as num).toInt(), + currentFuel: (json['currentFuel'] as num).toInt(), +); + +Map _$$PlanetUnlockResponseModelImplToJson( + _$PlanetUnlockResponseModelImpl instance, +) => { + 'planet': instance.planet, + 'fuelConsumed': instance.fuelConsumed, + 'currentFuel': instance.currentFuel, +}; diff --git a/test/features/exploration/data/models/unlock_response_models_test.dart b/test/features/exploration/data/models/unlock_response_models_test.dart new file mode 100644 index 0000000..6903897 --- /dev/null +++ b/test/features/exploration/data/models/unlock_response_models_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/exploration/data/models/unlock_response_models.dart'; + +void main() { + test('RegionUnlockResponseModel — toEntity', () { + final json = { + 'region': { + 'id': 'japan', + 'name': '일본', + 'isUnlocked': true, + 'isCleared': true, + 'unlockedAt': '2026-04-16T11:00:00Z', + }, + 'fuelConsumed': 5, + 'currentFuel': 95, + 'planetCleared': false, + }; + final entity = RegionUnlockResponseModel.fromJson(json).toEntity(); + expect(entity.planetCleared, false); + expect(entity.currentFuel, 95); + }); + + test('PlanetUnlockResponseModel — toEntity', () { + final json = { + 'planet': { + 'id': 'mars', + 'name': '화성', + 'isUnlocked': true, + 'isCleared': false, + }, + 'fuelConsumed': 10, + 'currentFuel': 40, + }; + final entity = PlanetUnlockResponseModel.fromJson(json).toEntity(); + expect(entity.currentFuel, 40); + }); +} From 253e85c57324e802e41fc12c47330d2974e1988a Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 19:03:46 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat=20:=20ExplorationRemoteDataSource=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exploration_remote_datasource.dart | 34 ++++ .../exploration_remote_datasource.g.dart | 164 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 lib/features/exploration/data/datasources/exploration_remote_datasource.dart create mode 100644 lib/features/exploration/data/datasources/exploration_remote_datasource.g.dart diff --git a/lib/features/exploration/data/datasources/exploration_remote_datasource.dart b/lib/features/exploration/data/datasources/exploration_remote_datasource.dart new file mode 100644 index 0000000..67fe7eb --- /dev/null +++ b/lib/features/exploration/data/datasources/exploration_remote_datasource.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/constants/api_endpoints.dart'; +import '../models/planet_response_model.dart'; +import '../models/region_response_model.dart'; +import '../models/unlock_response_models.dart'; + +part 'exploration_remote_datasource.g.dart'; + +/// Exploration 백엔드 API 클라이언트 (api-docs.json Exploration 태그 4 엔드포인트). +@RestApi() +abstract class ExplorationRemoteDataSource { + factory ExplorationRemoteDataSource(Dio dio) = _ExplorationRemoteDataSource; + + /// 전체 행성 목록 — 200 + @GET(ApiEndpoints.explorationPlanets) + Future> getPlanets(); + + /// 행성 하위 지역 목록 — 200 / 404 PLANET_NOT_FOUND + @GET('/api/explorations/planets/{planetId}/regions') + Future> getRegions( + @Path('planetId') String planetId); + + /// 지역 해금 — 200 / 400 / 404 + @POST('/api/explorations/regions/{regionId}/unlock') + Future unlockRegion( + @Path('regionId') String regionId); + + /// 행성 해금 — 200 / 400 / 404 + @POST('/api/explorations/planets/{planetId}/unlock') + Future unlockPlanet( + @Path('planetId') String planetId); +} diff --git a/lib/features/exploration/data/datasources/exploration_remote_datasource.g.dart b/lib/features/exploration/data/datasources/exploration_remote_datasource.g.dart new file mode 100644 index 0000000..e2d5827 --- /dev/null +++ b/lib/features/exploration/data/datasources/exploration_remote_datasource.g.dart @@ -0,0 +1,164 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'exploration_remote_datasource.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter + +class _ExplorationRemoteDataSource implements ExplorationRemoteDataSource { + _ExplorationRemoteDataSource(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> getPlanets() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/explorations/planets', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late List _value; + try { + _value = _result.data! + .map( + (dynamic i) => + PlanetResponseModel.fromJson(i as Map), + ) + .toList(); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future> getRegions(String planetId) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/explorations/planets/${planetId}/regions', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late List _value; + try { + _value = _result.data! + .map( + (dynamic i) => + RegionResponseModel.fromJson(i as Map), + ) + .toList(); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future unlockRegion(String regionId) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/explorations/regions/${regionId}/unlock', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late RegionUnlockResponseModel _value; + try { + _value = RegionUnlockResponseModel.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future unlockPlanet(String planetId) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/explorations/planets/${planetId}/unlock', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late PlanetUnlockResponseModel _value; + try { + _value = PlanetUnlockResponseModel.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} From 635dce73c94c59de0016f45261f054283220628b Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 19:06:07 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat=20:=20ExplorationRemoteRepositoryImp?= =?UTF-8?q?l=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exploration_remote_repository_impl.dart | 122 ++++++++++++++++++ ...ploration_remote_repository_impl_test.dart | 94 ++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart create mode 100644 test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart diff --git a/lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart b/lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart new file mode 100644 index 0000000..270c32f --- /dev/null +++ b/lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart @@ -0,0 +1,122 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/network/dio_exception_handler.dart'; +import '../../../fuel/domain/exceptions/fuel_exceptions.dart'; +import '../../domain/entities/exploration_node_entity.dart'; +import '../../domain/entities/exploration_progress_entity.dart'; +import '../../domain/entities/unlock_result_entities.dart'; +import '../../domain/exceptions/exploration_exceptions.dart'; +import '../datasources/exploration_remote_datasource.dart'; + +/// 서버 source-of-truth 탐험 Repository (인증 사용자). +/// +/// 행성 목록을 받을 때 progress를 in-memory 캐시에 저장해, getProgress가 +/// 추가 네트워크 호출 없이 반환하도록 한다. +class ExplorationRemoteRepositoryImpl { + final ExplorationRemoteDataSource _remote; + + ExplorationRemoteRepositoryImpl(this._remote); + + List _planetsCache = const []; + final Map _progressCache = {}; + + Future> getPlanets() async { + try { + final models = await _remote.getPlanets(); + _planetsCache = models.map((m) => m.toEntity()).toList(growable: false); + _progressCache + ..clear() + ..addEntries(models.map((m) => MapEntry(m.id, m.progressEntity()))); + return _planetsCache; + } on DioException catch (e) { + throw DioExceptionHandler.handle(e); + } + } + + Future getPlanet(String planetId) async { + final cached = _findById(_planetsCache, planetId); + if (cached != null) return cached; + final found = _findById(await getPlanets(), planetId); + if (found == null) throw const ExplorationNodeNotFoundException(); + return found; + } + + ExplorationNodeEntity? _findById( + List list, + String id, + ) { + for (final node in list) { + if (node.id == id) return node; + } + return null; + } + + Future> getRegions(String planetId) async { + try { + final models = await _remote.getRegions(planetId); + return models.map((m) => m.toEntity()).toList(growable: false); + } on DioException catch (e) { + throw _mapUnlockError(e); + } + } + + Future unlockRegion(String regionId) async { + try { + final res = await _remote.unlockRegion(regionId); + return res.toEntity(); + } on DioException catch (e) { + throw _mapUnlockError(e); + } + } + + Future unlockPlanet(String planetId) async { + try { + final res = await _remote.unlockPlanet(planetId); + return res.toEntity(); + } on DioException catch (e) { + throw _mapUnlockError(e); + } + } + + Future getProgress(String planetId) async { + final cached = _progressCache[planetId]; + if (cached != null) return cached; + await getPlanets(); + return _progressCache[planetId] ?? + ExplorationProgressEntity( + nodeId: planetId, + clearedChildren: 0, + totalChildren: 0, + ); + } + + /// 서버 데이터는 탈퇴/로그아웃 API가 정리. 캐시만 비운다 (호출 경로 없음). + Future clearAll() async { + _planetsCache = const []; + _progressCache.clear(); + } + + /// unlock/regions 에러 본문 code → 도메인 예외 매핑. + Object _mapUnlockError(DioException e) { + final data = e.response?.data; + if (data is Map) { + switch (data['code']) { + case 'INSUFFICIENT_FUEL': + return InsufficientFuelException( + requiredAmount: (data['requiredFuel'] as num?)?.toInt() ?? 0, + available: (data['currentFuel'] as num?)?.toInt() ?? 0, + ); + case 'ALREADY_UNLOCKED': + return const NodeAlreadyUnlockedException(); + case 'PLANET_LOCKED': + return const PlanetLockedException(); + case 'PREREQUISITE_NOT_CLEARED': + return const PrerequisiteNotClearedException(); + case 'REGION_NOT_FOUND': + case 'PLANET_NOT_FOUND': + return const ExplorationNodeNotFoundException(); + } + } + return DioExceptionHandler.handle(e); + } +} diff --git a/test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart b/test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart new file mode 100644 index 0000000..f4419d6 --- /dev/null +++ b/test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart @@ -0,0 +1,94 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:space_study_ship/features/exploration/data/datasources/exploration_remote_datasource.dart'; +import 'package:space_study_ship/features/exploration/data/models/planet_response_model.dart'; +import 'package:space_study_ship/features/exploration/data/models/unlock_response_models.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_remote_repository_impl.dart'; +import 'package:space_study_ship/features/exploration/domain/exceptions/exploration_exceptions.dart'; +import 'package:space_study_ship/features/fuel/domain/exceptions/fuel_exceptions.dart'; + +class _MockRemote extends Mock implements ExplorationRemoteDataSource {} + +void main() { + late _MockRemote remote; + late ExplorationRemoteRepositoryImpl repo; + + setUp(() { + remote = _MockRemote(); + repo = ExplorationRemoteRepositoryImpl(remote); + }); + + PlanetResponseModel planet(String id) => PlanetResponseModel( + id: id, + name: id, + nodeType: 'planet', + depth: 2, + icon: id, + requiredFuel: 10, + isUnlocked: false, + isCleared: false, + sortOrder: 0, + progress: const ProgressResponseModel(clearedChildren: 1, totalChildren: 4), + ); + + DioException unlockError(String code, {int? requiredFuel, int? currentFuel}) => + DioException( + requestOptions: RequestOptions(path: '/api/explorations/regions/x/unlock'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 400, + data: { + 'code': code, + 'message': 'msg', + if (requiredFuel != null) 'requiredFuel': requiredFuel, + if (currentFuel != null) 'currentFuel': currentFuel, + }, + ), + ); + + test('getPlanets — 매핑 + getProgress 캐시 사용', () async { + when(() => remote.getPlanets()).thenAnswer((_) async => [planet('mars')]); + + final planets = await repo.getPlanets(); + expect(planets.single.id, 'mars'); + + // getProgress는 추가 fetch 없이 캐시에서 반환 + final progress = await repo.getProgress('mars'); + expect(progress.clearedChildren, 1); + expect(progress.totalChildren, 4); + verify(() => remote.getPlanets()).called(1); // 단 1회 + }); + + test('unlockRegion INSUFFICIENT_FUEL → fuel InsufficientFuelException', () async { + when(() => remote.unlockRegion('seoul')).thenThrow( + unlockError('INSUFFICIENT_FUEL', requiredFuel: 10, currentFuel: 3), + ); + + expect( + () => repo.unlockRegion('seoul'), + throwsA( + isA() + .having((e) => e.requiredAmount, 'requiredAmount', 10) + .having((e) => e.available, 'available', 3), + ), + ); + }); + + test('unlockRegion ALREADY_UNLOCKED → NodeAlreadyUnlockedException', () async { + when(() => remote.unlockRegion('seoul')) + .thenThrow(unlockError('ALREADY_UNLOCKED')); + expect( + () => repo.unlockRegion('seoul'), + throwsA(isA()), + ); + }); + + test('unlockPlanet 성공 → 결과 엔티티', () async { + when(() => remote.unlockPlanet('mars')).thenAnswer( + (_) async => const PlanetUnlockResponseModel(fuelConsumed: 10, currentFuel: 40), + ); + final result = await repo.unlockPlanet('mars'); + expect(result.currentFuel, 40); + }); +} From 091135958e6869a951262f8a0427aed3bf50e8de Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 19:14:38 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat=20:=20=ED=83=90=ED=97=98=20Repositor?= =?UTF-8?q?y=20async=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20(provider=C2=B7=ED=99=94=EB=A9=B4)=20#8?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/providers/auth_provider.dart | 2 +- .../providers/badge_provider.dart | 9 +- .../exploration_remote_repository_impl.dart | 10 +- .../exploration_repository_impl.dart | 44 +++--- .../repositories/exploration_repository.dart | 25 ++-- .../providers/exploration_provider.dart | 134 +++++++++--------- .../providers/exploration_provider.g.dart | 129 +++++++++++------ .../screens/exploration_detail_screen.dart | 15 +- .../screens/location_detail_screen.dart | 37 ++++- .../presentation/widgets/planet_node.dart | 9 +- .../presentation/screens/explore_screen.dart | 76 +++++----- ...xploration_local_repository_impl_test.dart | 29 ++++ 12 files changed, 328 insertions(+), 191 deletions(-) create mode 100644 test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index ec9eb5e..f774f97 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -352,7 +352,7 @@ class AuthNotifier extends _$AuthNotifier { () => ref.read(localTodoDataSourceProvider).clearAll(), () => ref.read(timerSessionRepositoryProvider).clearAll(), () => ref.read(fuelLocalDataSourceProvider).clearAll(), - () => ref.read(explorationRepositoryProvider).clearAll(), + () => ref.read(explorationLocalDataSourceProvider).clearAll(), () => ref.read(badgeRepositoryProvider).clearAll(), ]; diff --git a/lib/features/badge/presentation/providers/badge_provider.dart b/lib/features/badge/presentation/providers/badge_provider.dart index 86e91be..a435081 100644 --- a/lib/features/badge/presentation/providers/badge_provider.dart +++ b/lib/features/badge/presentation/providers/badge_provider.dart @@ -8,6 +8,7 @@ import '../../domain/entities/badge_entity.dart'; import '../../domain/repositories/badge_repository.dart'; import '../../../timer/presentation/providers/study_stats_provider.dart'; import '../../../fuel/presentation/providers/fuel_provider.dart'; +import '../../../exploration/domain/entities/exploration_node_entity.dart'; import '../../../exploration/presentation/providers/exploration_provider.dart'; part 'badge_provider.g.dart'; @@ -56,13 +57,17 @@ class BadgeNotifier extends _$BadgeNotifier { final fuelStateAsync = ref.read(fuelNotifierProvider); final fuelState = fuelStateAsync.valueOrNull; if (fuelState == null) return []; // 잔량 아직 로드 전이면 배지 해금 평가 skip - final planets = ref.read(explorationNotifierProvider); + final planets = + ref.read(explorationNotifierProvider).valueOrNull ?? + const []; final unlockedPlanets = planets.where((p) => p.isUnlocked).length; // 지역 해금 수 계산 int unlockedRegions = 0; for (final planet in planets) { - final regions = ref.read(regionListNotifierProvider(planet.id)); + final regions = + ref.read(regionListNotifierProvider(planet.id)).valueOrNull ?? + const []; unlockedRegions += regions.where((r) => r.isUnlocked).length; } diff --git a/lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart b/lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart index 270c32f..e7878d2 100644 --- a/lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart +++ b/lib/features/exploration/data/repositories/exploration_remote_repository_impl.dart @@ -6,13 +6,14 @@ import '../../domain/entities/exploration_node_entity.dart'; import '../../domain/entities/exploration_progress_entity.dart'; import '../../domain/entities/unlock_result_entities.dart'; import '../../domain/exceptions/exploration_exceptions.dart'; +import '../../domain/repositories/exploration_repository.dart'; import '../datasources/exploration_remote_datasource.dart'; /// 서버 source-of-truth 탐험 Repository (인증 사용자). /// /// 행성 목록을 받을 때 progress를 in-memory 캐시에 저장해, getProgress가 /// 추가 네트워크 호출 없이 반환하도록 한다. -class ExplorationRemoteRepositoryImpl { +class ExplorationRemoteRepositoryImpl implements ExplorationRepository { final ExplorationRemoteDataSource _remote; ExplorationRemoteRepositoryImpl(this._remote); @@ -20,6 +21,7 @@ class ExplorationRemoteRepositoryImpl { List _planetsCache = const []; final Map _progressCache = {}; + @override Future> getPlanets() async { try { final models = await _remote.getPlanets(); @@ -33,6 +35,7 @@ class ExplorationRemoteRepositoryImpl { } } + @override Future getPlanet(String planetId) async { final cached = _findById(_planetsCache, planetId); if (cached != null) return cached; @@ -51,6 +54,7 @@ class ExplorationRemoteRepositoryImpl { return null; } + @override Future> getRegions(String planetId) async { try { final models = await _remote.getRegions(planetId); @@ -60,6 +64,7 @@ class ExplorationRemoteRepositoryImpl { } } + @override Future unlockRegion(String regionId) async { try { final res = await _remote.unlockRegion(regionId); @@ -69,6 +74,7 @@ class ExplorationRemoteRepositoryImpl { } } + @override Future unlockPlanet(String planetId) async { try { final res = await _remote.unlockPlanet(planetId); @@ -78,6 +84,7 @@ class ExplorationRemoteRepositoryImpl { } } + @override Future getProgress(String planetId) async { final cached = _progressCache[planetId]; if (cached != null) return cached; @@ -91,6 +98,7 @@ class ExplorationRemoteRepositoryImpl { } /// 서버 데이터는 탈퇴/로그아웃 API가 정리. 캐시만 비운다 (호출 경로 없음). + @override Future clearAll() async { _planetsCache = const []; _progressCache.clear(); diff --git a/lib/features/exploration/data/repositories/exploration_repository_impl.dart b/lib/features/exploration/data/repositories/exploration_repository_impl.dart index e42273c..0676824 100644 --- a/lib/features/exploration/data/repositories/exploration_repository_impl.dart +++ b/lib/features/exploration/data/repositories/exploration_repository_impl.dart @@ -2,21 +2,22 @@ import 'package:flutter/foundation.dart'; import '../../domain/entities/exploration_node_entity.dart'; import '../../domain/entities/exploration_progress_entity.dart'; +import '../../domain/entities/unlock_result_entities.dart'; import '../../domain/repositories/exploration_repository.dart'; import '../datasources/exploration_local_datasource.dart'; import '../seed/exploration_seed_data.dart'; -/// 로컬 탐험 Repository 구현체 +/// 로컬 탐험 Repository 구현체 (게스트). /// -/// 시드 데이터(정적) + SharedPreferences(상태)를 머지하여 완성된 Entity를 반환. -/// 향후 백엔드 연동 시 ExplorationRemoteRepositoryImpl로 교체. +/// 시드 데이터(정적) + SharedPreferences(상태)를 머지. 연료 차감은 Notifier가 +/// 별도 수행하므로 결과 엔티티의 currentFuel은 -1 센티넬. class ExplorationLocalRepositoryImpl implements ExplorationRepository { final ExplorationLocalDataSource _localDataSource; ExplorationLocalRepositoryImpl(this._localDataSource); @override - List getPlanets() { + Future> getPlanets() async { final states = _localDataSource.getAllStates(); return ExplorationSeedData.planets.map((planet) { final state = states[planet.id]; @@ -30,7 +31,7 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { } @override - ExplorationNodeEntity getPlanet(String planetId) { + Future getPlanet(String planetId) async { final planet = ExplorationSeedData.getPlanet(planetId); final states = _localDataSource.getAllStates(); final state = states[planetId]; @@ -43,7 +44,7 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { } @override - List getRegions(String planetId) { + Future> getRegions(String planetId) async { final states = _localDataSource.getAllStates(); return ExplorationSeedData.getRegions(planetId).map((region) { final state = states[region.id]; @@ -57,9 +58,8 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { } @override - Future unlockRegion(String regionId) async { + Future unlockRegion(String regionId) async { final now = DateTime.now(); - // Region은 해금 = 클리어 (연료 소비만으로 탐험 완료) await _localDataSource.saveNodeState( ExplorationNodeState( nodeId: regionId, @@ -68,13 +68,15 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { unlockedAt: now, ), ); - - // 부모 행성의 자동 클리어 체크 - await _checkPlanetAutoComplete(regionId); + final planetCleared = await _checkPlanetAutoComplete(regionId); + return RegionUnlockResultEntity( + planetCleared: planetCleared, + currentFuel: -1, + ); } @override - Future unlockPlanet(String planetId) async { + Future unlockPlanet(String planetId) async { await _localDataSource.saveNodeState( ExplorationNodeState( nodeId: planetId, @@ -82,11 +84,12 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { unlockedAt: DateTime.now(), ), ); + return const PlanetUnlockResultEntity(currentFuel: -1); } @override - ExplorationProgressEntity getProgress(String planetId) { - final regions = getRegions(planetId); + Future getProgress(String planetId) async { + final regions = await getRegions(planetId); final cleared = regions.where((r) => r.isCleared).length; return ExplorationProgressEntity( nodeId: planetId, @@ -100,10 +103,8 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { await _localDataSource.clearAll(); } - /// 부모 행성 자동 클리어 체크 - /// - /// 모든 하위 지역이 클리어되면 행성도 자동으로 클리어 처리. - Future _checkPlanetAutoComplete(String regionId) async { + /// 부모 행성 자동 클리어 체크. 새로 클리어되면 true. + Future _checkPlanetAutoComplete(String regionId) async { String? parentPlanetId; for (final entry in ExplorationSeedData.regions.entries) { if (entry.value.any((r) => r.id == regionId)) { @@ -111,9 +112,9 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { break; } } - if (parentPlanetId == null) return; + if (parentPlanetId == null) return false; - final regions = getRegions(parentPlanetId); + final regions = await getRegions(parentPlanetId); final allCleared = regions.every((r) => r.isCleared); if (allCleared) { await _localDataSource.saveNodeState( @@ -124,7 +125,8 @@ class ExplorationLocalRepositoryImpl implements ExplorationRepository { unlockedAt: DateTime.now(), ), ); - debugPrint('🎉 행성 $parentPlanetId 자동 클리어!'); + debugPrint('행성 $parentPlanetId 자동 클리어'); } + return allCleared; } } diff --git a/lib/features/exploration/domain/repositories/exploration_repository.dart b/lib/features/exploration/domain/repositories/exploration_repository.dart index 17ae4bc..8d75424 100644 --- a/lib/features/exploration/domain/repositories/exploration_repository.dart +++ b/lib/features/exploration/domain/repositories/exploration_repository.dart @@ -1,28 +1,23 @@ import '../entities/exploration_node_entity.dart'; import '../entities/exploration_progress_entity.dart'; +import '../entities/unlock_result_entities.dart'; /// 탐험 Repository 인터페이스 /// /// 게스트: [ExplorationLocalRepositoryImpl] (SharedPreferences) -/// 소셜 로그인(향후): ExplorationRemoteRepositoryImpl (Backend API) +/// 인증: [ExplorationRemoteRepositoryImpl] (Backend API) abstract class ExplorationRepository { - /// 전체 행성 목록 (해금 상태 반영) - List getPlanets(); + Future> getPlanets(); + Future getPlanet(String planetId); + Future> getRegions(String planetId); - /// 특정 행성 조회 - ExplorationNodeEntity getPlanet(String planetId); + /// 지역 해금 (해금 = 클리어). 게스트 구현의 [RegionUnlockResultEntity.currentFuel]은 -1. + Future unlockRegion(String regionId); - /// 특정 행성의 지역 목록 (해금 상태 반영) - List getRegions(String planetId); + /// 행성 해금. 게스트 구현의 [PlanetUnlockResultEntity.currentFuel]은 -1. + Future unlockPlanet(String planetId); - /// 지역 해금 (해금 = 클리어) - Future unlockRegion(String regionId); - - /// 행성 해금 - Future unlockPlanet(String planetId); - - /// 진행도 계산 - ExplorationProgressEntity getProgress(String planetId); + Future getProgress(String planetId); /// 전체 삭제 (게스트 로그아웃 시) Future clearAll(); diff --git a/lib/features/exploration/presentation/providers/exploration_provider.dart b/lib/features/exploration/presentation/providers/exploration_provider.dart index 0862eab..3da2bc0 100644 --- a/lib/features/exploration/presentation/providers/exploration_provider.dart +++ b/lib/features/exploration/presentation/providers/exploration_provider.dart @@ -2,7 +2,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../../core/network/dio_client.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; import '../../data/datasources/exploration_local_datasource.dart'; +import '../../data/datasources/exploration_remote_datasource.dart'; +import '../../data/repositories/exploration_remote_repository_impl.dart'; import '../../data/repositories/exploration_repository_impl.dart'; import '../../domain/entities/exploration_node_entity.dart'; import '../../domain/entities/exploration_progress_entity.dart'; @@ -23,21 +27,26 @@ ExplorationLocalDataSource explorationLocalDataSource(Ref ref) { ); } +// === Remote DataSource === + +@Riverpod(keepAlive: true) +ExplorationRemoteDataSource explorationRemoteDataSource(Ref ref) { + return ExplorationRemoteDataSource(ref.watch(dioProvider)); +} + // === Repository (auth 기반 스왑) === -/// 현재: 게스트/소셜 로그인 모두 로컬 Repository 사용 -/// 향후: isGuest == false 시 ExplorationRemoteRepositoryImpl로 교체 -@riverpod +/// 인증 사용자는 서버, 게스트는 로컬 Repository. +@Riverpod(keepAlive: true) ExplorationRepository explorationRepository(Ref ref) { - // 향후 백엔드 연동 시: - // final isGuest = ref.watch(isGuestProvider); - // if (!isGuest) { - // return ExplorationRemoteRepositoryImpl( - // ref.watch(explorationRemoteDataSourceProvider), - // ); - // } - final dataSource = ref.watch(explorationLocalDataSourceProvider); - return ExplorationLocalRepositoryImpl(dataSource); + if (ref.watch(isAuthenticatedProvider)) { + return ExplorationRemoteRepositoryImpl( + ref.watch(explorationRemoteDataSourceProvider), + ); + } + return ExplorationLocalRepositoryImpl( + ref.watch(explorationLocalDataSourceProvider), + ); } // === State Notifiers === @@ -49,17 +58,13 @@ ExplorationRepository explorationRepository(Ref ref) { @Riverpod(keepAlive: true) class ExplorationNotifier extends _$ExplorationNotifier { @override - List build() { - final repository = ref.watch(explorationRepositoryProvider); - return repository.getPlanets(); + Future> build() async { + return ref.watch(explorationRepositoryProvider).getPlanets(); } - /// 이전 행성이 해금되었는지 확인 - /// - /// sortOrder 기준으로 바로 앞 행성이 해금 상태여야 true. - /// 첫 번째 행성(sortOrder 0)은 항상 true. + /// 이전 행성이 해금되었는지 (sortOrder 기준 바로 앞 행성 클리어 필요). bool canUnlockPlanet(String planetId) { - final planets = state; + final planets = state.valueOrNull ?? const []; final targetIndex = planets.indexWhere((p) => p.id == planetId); if (targetIndex < 0) return false; if (targetIndex == 0) return true; @@ -68,32 +73,30 @@ class ExplorationNotifier extends _$ExplorationNotifier { bool _isUnlocking = false; - /// 행성 해금 (순서 검증 + 연료 소비 + 상태 저장) - /// - /// 이전 행성 미해금 시 [StateError] throw. - /// 연료 부족 시 [InsufficientFuelException] throw. + /// 행성 해금 (순서 검증 + 연료 + 상태). Future unlockPlanet(String planetId, int requiredFuel) async { if (_isUnlocking) return; _isUnlocking = true; try { - // 0. 순서 검증: 이전 행성이 해금되어야 함 if (!canUnlockPlanet(planetId)) { throw StateError('이전 행성을 먼저 해금해야 합니다.'); } - // 1. 연료 차감 - await ref - .read(fuelNotifierProvider.notifier) - .consumeFuel(amount: requiredFuel, nodeId: planetId); - - // 2. 해금 상태 저장 final repository = ref.read(explorationRepositoryProvider); - await repository.unlockPlanet(planetId); + if (ref.read(isAuthenticatedProvider)) { + // 서버가 원자적으로 연료 차감 → 잔량 재조회만. + await repository.unlockPlanet(planetId); + ref.invalidate(fuelNotifierProvider); + } else { + // 게스트: 로컬 연료 차감 후 해금. + await ref + .read(fuelNotifierProvider.notifier) + .consumeFuel(amount: requiredFuel, nodeId: planetId); + await repository.unlockPlanet(planetId); + } - // 3. 상태 갱신 - _reload(); + await _reload(); - // 배지 해금 체크 (탐험 기반) — 실패해도 탐험 해금에 영향 없음 try { await ref.read(badgeNotifierProvider.notifier).checkAndUnlock(); } catch (e) { @@ -104,12 +107,13 @@ class ExplorationNotifier extends _$ExplorationNotifier { } } - /// 상태 새로고침 (지역 해금 후 행성 목록도 갱신) - void refresh() => _reload(); + /// 상태 새로고침 (지역 해금 후 행성 목록 갱신). + Future refresh() => _reload(); - void _reload() { - final repository = ref.read(explorationRepositoryProvider); - state = repository.getPlanets(); + Future _reload() async { + state = await AsyncValue.guard( + () => ref.read(explorationRepositoryProvider).getPlanets(), + ); } } @@ -117,36 +121,33 @@ class ExplorationNotifier extends _$ExplorationNotifier { @riverpod class RegionListNotifier extends _$RegionListNotifier { @override - List build(String planetId) { - final repository = ref.watch(explorationRepositoryProvider); - return repository.getRegions(planetId); + Future> build(String planetId) async { + return ref.watch(explorationRepositoryProvider).getRegions(planetId); } bool _isUnlocking = false; - /// 지역 해금 (연료 소비 + 상태 저장 + 자동 클리어) - /// - /// 연료 부족 시 [InsufficientFuelException] throw. + /// 지역 해금 (연료 + 상태 + 자동 클리어). Future unlockRegion(String regionId, int requiredFuel) async { if (_isUnlocking) return; _isUnlocking = true; try { - // 1. 연료 차감 - await ref - .read(fuelNotifierProvider.notifier) - .consumeFuel(amount: requiredFuel, nodeId: regionId); - - // 2. 해금 + 클리어 상태 저장 final repository = ref.read(explorationRepositoryProvider); - await repository.unlockRegion(regionId); + if (ref.read(isAuthenticatedProvider)) { + await repository.unlockRegion(regionId); + ref.invalidate(fuelNotifierProvider); + } else { + await ref + .read(fuelNotifierProvider.notifier) + .consumeFuel(amount: requiredFuel, nodeId: regionId); + await repository.unlockRegion(regionId); + } - // 3. 지역 목록 갱신 - state = repository.getRegions(planetId); + state = await AsyncValue.guard(() => repository.getRegions(planetId)); - // 4. 행성 목록도 갱신 (자동 클리어 반영) - ref.read(explorationNotifierProvider.notifier).refresh(); + // 행성 목록 갱신 (자동 클리어/progress 반영) + await ref.read(explorationNotifierProvider.notifier).refresh(); - // 배지 해금 체크 (탐험 기반) — 실패해도 지역 해금에 영향 없음 try { await ref.read(badgeNotifierProvider.notifier).checkAndUnlock(); } catch (e) { @@ -158,11 +159,16 @@ class RegionListNotifier extends _$RegionListNotifier { } } -/// 특정 행성의 진행도 +/// 특정 행성의 진행도. +/// +/// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 +/// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 +/// 행성 응답에 내장.) @riverpod -ExplorationProgressEntity explorationProgress(Ref ref, String planetId) { - // regionListNotifier를 watch하여 지역 상태 변경 시 자동 갱신 - ref.watch(regionListNotifierProvider(planetId)); - final repository = ref.watch(explorationRepositoryProvider); - return repository.getProgress(planetId); +Future explorationProgress( + Ref ref, + String planetId, +) { + ref.watch(explorationNotifierProvider); + return ref.watch(explorationRepositoryProvider).getProgress(planetId); } diff --git a/lib/features/exploration/presentation/providers/exploration_provider.g.dart b/lib/features/exploration/presentation/providers/exploration_provider.g.dart index 4dcb5db..c010124 100644 --- a/lib/features/exploration/presentation/providers/exploration_provider.g.dart +++ b/lib/features/exploration/presentation/providers/exploration_provider.g.dart @@ -28,31 +28,48 @@ final explorationLocalDataSourceProvider = // ignore: unused_element typedef ExplorationLocalDataSourceRef = AutoDisposeProviderRef; -String _$explorationRepositoryHash() => - r'66f9f50ab132d74e023d5b2f096d2a7fbfaa6444'; - -/// 현재: 게스트/소셜 로그인 모두 로컬 Repository 사용 -/// 향후: isGuest == false 시 ExplorationRemoteRepositoryImpl로 교체 -/// -/// Copied from [explorationRepository]. -@ProviderFor(explorationRepository) -final explorationRepositoryProvider = - AutoDisposeProvider.internal( - explorationRepository, - name: r'explorationRepositoryProvider', +String _$explorationRemoteDataSourceHash() => + r'2c06143a1f3225df177493831d068e4c49ad1570'; + +/// See also [explorationRemoteDataSource]. +@ProviderFor(explorationRemoteDataSource) +final explorationRemoteDataSourceProvider = + Provider.internal( + explorationRemoteDataSource, + name: r'explorationRemoteDataSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$explorationRepositoryHash, + : _$explorationRemoteDataSourceHash, dependencies: null, allTransitiveDependencies: null, ); @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef ExplorationRepositoryRef = - AutoDisposeProviderRef; +typedef ExplorationRemoteDataSourceRef = + ProviderRef; +String _$explorationRepositoryHash() => + r'cc330188bf19edd52acd58225758621eada93bbc'; + +/// 인증 사용자는 서버, 게스트는 로컬 Repository. +/// +/// Copied from [explorationRepository]. +@ProviderFor(explorationRepository) +final explorationRepositoryProvider = Provider.internal( + explorationRepository, + name: r'explorationRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$explorationRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ExplorationRepositoryRef = ProviderRef; String _$explorationProgressHash() => - r'4bd32186a295bcf4e19a15bcd2802e05deb0b4d6'; + r'fed6d083e814c046242a76f06f72f82eb594dbc3'; /// Copied from Dart SDK class _SystemHash { @@ -75,22 +92,39 @@ class _SystemHash { } } -/// 특정 행성의 진행도 +/// 특정 행성의 진행도. +/// +/// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 +/// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 +/// 행성 응답에 내장.) /// /// Copied from [explorationProgress]. @ProviderFor(explorationProgress) const explorationProgressProvider = ExplorationProgressFamily(); -/// 특정 행성의 진행도 +/// 특정 행성의 진행도. +/// +/// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 +/// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 +/// 행성 응답에 내장.) /// /// Copied from [explorationProgress]. -class ExplorationProgressFamily extends Family { - /// 특정 행성의 진행도 +class ExplorationProgressFamily + extends Family> { + /// 특정 행성의 진행도. + /// + /// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 + /// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 + /// 행성 응답에 내장.) /// /// Copied from [explorationProgress]. const ExplorationProgressFamily(); - /// 특정 행성의 진행도 + /// 특정 행성의 진행도. + /// + /// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 + /// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 + /// 행성 응답에 내장.) /// /// Copied from [explorationProgress]. ExplorationProgressProvider call(String planetId) { @@ -119,12 +153,20 @@ class ExplorationProgressFamily extends Family { String? get name => r'explorationProgressProvider'; } -/// 특정 행성의 진행도 +/// 특정 행성의 진행도. +/// +/// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 +/// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 +/// 행성 응답에 내장.) /// /// Copied from [explorationProgress]. class ExplorationProgressProvider - extends AutoDisposeProvider { - /// 특정 행성의 진행도 + extends AutoDisposeFutureProvider { + /// 특정 행성의 진행도. + /// + /// 행성 목록 변경 시 갱신. (regionListNotifier를 watch하면 인증 모드에서 + /// 행성마다 지역 API가 호출되므로 행성 목록만 watch한다 — 서버 progress는 + /// 행성 응답에 내장.) /// /// Copied from [explorationProgress]. ExplorationProgressProvider(String planetId) @@ -155,7 +197,10 @@ class ExplorationProgressProvider @override Override overrideWith( - ExplorationProgressEntity Function(ExplorationProgressRef provider) create, + FutureOr Function( + ExplorationProgressRef provider, + ) + create, ) { return ProviderOverride( origin: this, @@ -172,7 +217,7 @@ class ExplorationProgressProvider } @override - AutoDisposeProviderElement createElement() { + AutoDisposeFutureProviderElement createElement() { return _ExplorationProgressProviderElement(this); } @@ -193,13 +238,13 @@ class ExplorationProgressProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element mixin ExplorationProgressRef - on AutoDisposeProviderRef { + on AutoDisposeFutureProviderRef { /// The parameter `planetId` of this provider. String get planetId; } class _ExplorationProgressProviderElement - extends AutoDisposeProviderElement + extends AutoDisposeFutureProviderElement with ExplorationProgressRef { _ExplorationProgressProviderElement(super.provider); @@ -208,7 +253,7 @@ class _ExplorationProgressProviderElement } String _$explorationNotifierHash() => - r'5bafbe44ee667f2899b03435d886b8781f1191d9'; + r'd1d397d7c30fcb66b595cadda5bce2834e82ebec'; /// 행성 목록 상태 /// @@ -218,7 +263,10 @@ String _$explorationNotifierHash() => /// Copied from [ExplorationNotifier]. @ProviderFor(ExplorationNotifier) final explorationNotifierProvider = - NotifierProvider>.internal( + AsyncNotifierProvider< + ExplorationNotifier, + List + >.internal( ExplorationNotifier.new, name: r'explorationNotifierProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -228,15 +276,15 @@ final explorationNotifierProvider = allTransitiveDependencies: null, ); -typedef _$ExplorationNotifier = Notifier>; +typedef _$ExplorationNotifier = AsyncNotifier>; String _$regionListNotifierHash() => - r'aee531fb3209715c68491f42d65e86bdfc8adee6'; + r'582db187507042692eaf6acdd6bbe6ec0c4f8afe'; abstract class _$RegionListNotifier - extends BuildlessAutoDisposeNotifier> { + extends BuildlessAutoDisposeAsyncNotifier> { late final String planetId; - List build(String planetId); + FutureOr> build(String planetId); } /// 특정 행성의 지역 목록 (행성 ID 기반 family) @@ -248,7 +296,8 @@ const regionListNotifierProvider = RegionListNotifierFamily(); /// 특정 행성의 지역 목록 (행성 ID 기반 family) /// /// Copied from [RegionListNotifier]. -class RegionListNotifierFamily extends Family> { +class RegionListNotifierFamily + extends Family>> { /// 특정 행성의 지역 목록 (행성 ID 기반 family) /// /// Copied from [RegionListNotifier]. @@ -288,7 +337,7 @@ class RegionListNotifierFamily extends Family> { /// Copied from [RegionListNotifier]. class RegionListNotifierProvider extends - AutoDisposeNotifierProviderImpl< + AutoDisposeAsyncNotifierProviderImpl< RegionListNotifier, List > { @@ -322,7 +371,7 @@ class RegionListNotifierProvider final String planetId; @override - List runNotifierBuild( + FutureOr> runNotifierBuild( covariant RegionListNotifier notifier, ) { return notifier.build(planetId); @@ -345,7 +394,7 @@ class RegionListNotifierProvider } @override - AutoDisposeNotifierProviderElement< + AutoDisposeAsyncNotifierProviderElement< RegionListNotifier, List > @@ -370,14 +419,14 @@ class RegionListNotifierProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element mixin RegionListNotifierRef - on AutoDisposeNotifierProviderRef> { + on AutoDisposeAsyncNotifierProviderRef> { /// The parameter `planetId` of this provider. String get planetId; } class _RegionListNotifierProviderElement extends - AutoDisposeNotifierProviderElement< + AutoDisposeAsyncNotifierProviderElement< RegionListNotifier, List > diff --git a/lib/features/exploration/presentation/screens/exploration_detail_screen.dart b/lib/features/exploration/presentation/screens/exploration_detail_screen.dart index e14efcd..faf4ab3 100644 --- a/lib/features/exploration/presentation/screens/exploration_detail_screen.dart +++ b/lib/features/exploration/presentation/screens/exploration_detail_screen.dart @@ -100,14 +100,23 @@ class _ExplorationDetailScreenState Widget build(BuildContext context) { final planet = ref.watch( explorationNotifierProvider.select( - (planets) => planets.where((p) => p.id == widget.planetId).firstOrNull, + (async) => + async.valueOrNull?.where((p) => p.id == widget.planetId).firstOrNull, ), ); if (planet == null) { return const Scaffold(backgroundColor: AppColors.spaceBackground); } - final regions = ref.watch(regionListNotifierProvider(widget.planetId)); - final progress = ref.watch(explorationProgressProvider(widget.planetId)); + final regions = + ref.watch(regionListNotifierProvider(widget.planetId)).valueOrNull ?? + const []; + final progress = + ref.watch(explorationProgressProvider(widget.planetId)).valueOrNull ?? + ExplorationProgressEntity( + nodeId: widget.planetId, + clearedChildren: 0, + totalChildren: 0, + ); final currentFuelAsync = ref.watch(currentFuelProvider); final currentFuel = currentFuelAsync.valueOrNull ?? 0; diff --git a/lib/features/exploration/presentation/screens/location_detail_screen.dart b/lib/features/exploration/presentation/screens/location_detail_screen.dart index 2dfd768..f31e4fe 100644 --- a/lib/features/exploration/presentation/screens/location_detail_screen.dart +++ b/lib/features/exploration/presentation/screens/location_detail_screen.dart @@ -40,16 +40,29 @@ class _LocationDetailScreenState extends ConsumerState { late final PageController _pageController; int _currentPage = 0; + bool _didSetInitialPage = false; + @override void initState() { super.initState(); - // regionListNotifierProvider는 동기 StateNotifier로 항상 즉시 데이터 반환 - final regions = ref.read(regionListNotifierProvider(widget.planetId)); + _pageController = PageController(initialPage: 0); + } + + /// regions 로드 후 initialRegionId 페이지로 1회 점프. + void _maybeSetInitialPage(List regions) { + if (_didSetInitialPage || regions.isEmpty) return; + _didSetInitialPage = true; final initialIndex = regions.indexWhere( (r) => r.id == widget.initialRegionId, ); - _currentPage = initialIndex >= 0 ? initialIndex : 0; - _pageController = PageController(initialPage: _currentPage); + if (initialIndex > 0) { + _currentPage = initialIndex; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _pageController.hasClients) { + _pageController.jumpToPage(initialIndex); + } + }); + } } @override @@ -78,13 +91,23 @@ class _LocationDetailScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final regions = ref.watch(regionListNotifierProvider(widget.planetId)); - final progress = ref.watch(explorationProgressProvider(widget.planetId)); + final regions = + ref.watch(regionListNotifierProvider(widget.planetId)).valueOrNull ?? + const []; + _maybeSetInitialPage(regions); + final progress = + ref.watch(explorationProgressProvider(widget.planetId)).valueOrNull ?? + ExplorationProgressEntity( + nodeId: widget.planetId, + clearedChildren: 0, + totalChildren: 0, + ); final currentFuelAsync = ref.watch(currentFuelProvider); final currentFuel = currentFuelAsync.valueOrNull ?? 0; final planet = ref.watch( explorationNotifierProvider.select( - (planets) => planets.where((p) => p.id == widget.planetId).firstOrNull, + (async) => + async.valueOrNull?.where((p) => p.id == widget.planetId).firstOrNull, ), ); diff --git a/lib/features/exploration/presentation/widgets/planet_node.dart b/lib/features/exploration/presentation/widgets/planet_node.dart index e9e8df8..86751c0 100644 --- a/lib/features/exploration/presentation/widgets/planet_node.dart +++ b/lib/features/exploration/presentation/widgets/planet_node.dart @@ -8,6 +8,7 @@ import '../../../../core/constants/spacing_and_radius.dart'; import '../../../../core/constants/text_styles.dart'; import '../../../../core/constants/toss_design_tokens.dart'; import '../../domain/entities/exploration_node_entity.dart'; +import '../../domain/entities/exploration_progress_entity.dart'; import '../providers/exploration_provider.dart'; /// 맵 위 행성 노드 위젯 @@ -59,7 +60,13 @@ class _PlanetNodeState extends ConsumerState Widget build(BuildContext context) { final isLocked = !widget.node.isUnlocked; final isCleared = widget.node.isCleared; - final progress = ref.watch(explorationProgressProvider(widget.node.id)); + final progress = + ref.watch(explorationProgressProvider(widget.node.id)).valueOrNull ?? + ExplorationProgressEntity( + nodeId: widget.node.id, + clearedChildren: 0, + totalChildren: 0, + ); return GestureDetector( onTapDown: (_) => setState(() => _isPressed = true), diff --git a/lib/features/explore/presentation/screens/explore_screen.dart b/lib/features/explore/presentation/screens/explore_screen.dart index d10473e..cfda7da 100644 --- a/lib/features/explore/presentation/screens/explore_screen.dart +++ b/lib/features/explore/presentation/screens/explore_screen.dart @@ -8,6 +8,7 @@ import '../../../../core/constants/spacing_and_radius.dart'; import '../../../../core/constants/text_styles.dart'; import '../../../../core/widgets/animations/entrance_animations.dart'; import '../../../../core/utils/login_prompt_helper.dart'; +import '../../../../core/widgets/feedback/app_loading.dart'; import '../../../../core/widgets/feedback/app_snackbar.dart'; import '../../../auth/presentation/providers/auth_provider.dart'; import '../../../../core/widgets/states/space_empty_state.dart'; @@ -42,29 +43,7 @@ class ExploreScreen extends ConsumerWidget { final currentFuelAsync = ref.watch(currentFuelProvider); final currentFuel = currentFuelAsync.valueOrNull ?? 0; final isGuest = ref.watch(isGuestProvider); - final planets = ref.watch(explorationNotifierProvider); - - // 현재 위치: 가장 마지막으로 해금된 행성 - var currentPlanetId = ''; - for (int i = planets.length - 1; i >= 0; i--) { - if (planets[i].isUnlocked) { - currentPlanetId = planets[i].id; - break; - } - } - - // 상단/하단 inset 계산 (AppBar + 바텀 네비 영역까지 별 배경 확장) - final topInset = MediaQuery.of(context).padding.top + kToolbarHeight; - final bottomInset = - MediaQuery.of(context).padding.bottom + FloatingNavMetrics.totalHeight; - - // 맵 전체 높이 계산 (AppBar + 바텀 네비 영역 포함) - final mapHeight = - topInset + - _mapTopPadding + - (planets.length - 1) * _planetSpacing + - _mapBottomPadding + - bottomInset; + final planetsAsync = ref.watch(explorationNotifierProvider); return Scaffold( backgroundColor: AppColors.spaceBackground, @@ -90,18 +69,42 @@ class ExploreScreen extends ConsumerWidget { ), ], ), - body: planets.isEmpty - ? _buildEmptyState() - : _buildSpaceMap( - context, - ref, - planets, - currentPlanetId, - currentFuel, - isGuest, - mapHeight, - topInset, - ), + body: planetsAsync.when( + loading: () => const Center(child: AppLoading()), + error: (_, _) => _buildEmptyState(), + data: (planets) { + if (planets.isEmpty) return _buildEmptyState(); + + var currentPlanetId = ''; + for (int i = planets.length - 1; i >= 0; i--) { + if (planets[i].isUnlocked) { + currentPlanetId = planets[i].id; + break; + } + } + + final topInset = + MediaQuery.of(context).padding.top + kToolbarHeight; + final bottomInset = MediaQuery.of(context).padding.bottom + + FloatingNavMetrics.totalHeight; + final mapHeight = topInset + + _mapTopPadding + + (planets.length - 1) * _planetSpacing + + _mapBottomPadding + + bottomInset; + + return _buildSpaceMap( + context, + ref, + planets, + currentPlanetId, + currentFuel, + isGuest, + mapHeight, + topInset, + ); + }, + ), ); } @@ -231,7 +234,8 @@ class ExploreScreen extends ConsumerWidget { .read(explorationNotifierProvider.notifier) .canUnlockPlanet(planet.id); if (!canUnlock) { - final planets = ref.read(explorationNotifierProvider); + final planets = + ref.read(explorationNotifierProvider).valueOrNull ?? const []; final targetIndex = planets.indexWhere((p) => p.id == planet.id); if (targetIndex > 0) { final prevPlanet = planets[targetIndex - 1]; diff --git a/test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart b/test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart new file mode 100644 index 0000000..00545a6 --- /dev/null +++ b/test/features/exploration/data/repositories/exploration_local_repository_impl_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:space_study_ship/features/exploration/data/datasources/exploration_local_datasource.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_repository_impl.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future build() async { + final prefs = await SharedPreferences.getInstance(); + return ExplorationLocalRepositoryImpl(ExplorationLocalDataSource(prefs)); + } + + test('getPlanets — 시드 반환 (지구 해금 상태)', () async { + final repo = await build(); + final planets = await repo.getPlanets(); + expect(planets, isNotEmpty); + expect(planets.first.id, 'earth'); + expect(planets.first.isUnlocked, true); + }); + + test('unlockRegion — currentFuel 센티넬 -1, 상태 저장', () async { + final repo = await build(); + final result = await repo.unlockRegion('japan'); + expect(result.currentFuel, -1); + final regions = await repo.getRegions('earth'); + expect(regions.firstWhere((r) => r.id == 'japan').isCleared, true); + }); +} From 8f10404f8a62a710f9805da8afe199620aa2855c Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 19:19:55 +0900 Subject: [PATCH 11/14] =?UTF-8?q?test=20:=20=ED=83=90=ED=97=98=20Repositor?= =?UTF-8?q?y=20=EB=B6=84=EA=B8=B0=20=EB=B0=8F=20=EA=B2=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20=ED=9A=8C=EA=B7=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exploration_provider_test.dart | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 test/features/exploration/presentation/exploration_provider_test.dart diff --git a/test/features/exploration/presentation/exploration_provider_test.dart b/test/features/exploration/presentation/exploration_provider_test.dart new file mode 100644 index 0000000..d9aa12a --- /dev/null +++ b/test/features/exploration/presentation/exploration_provider_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:space_study_ship/features/auth/presentation/providers/auth_provider.dart'; +import 'package:space_study_ship/features/exploration/data/datasources/exploration_local_datasource.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_remote_repository_impl.dart'; +import 'package:space_study_ship/features/exploration/data/repositories/exploration_repository_impl.dart'; +import 'package:space_study_ship/features/exploration/presentation/providers/exploration_provider.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future container({required bool authed}) async { + final prefs = await SharedPreferences.getInstance(); + return ProviderContainer( + overrides: [ + explorationLocalDataSourceProvider.overrideWithValue( + ExplorationLocalDataSource(prefs), + ), + isAuthenticatedProvider.overrideWith((ref) => authed), + ], + ); + } + + test('게스트 → Local repo 주입', () async { + final c = await container(authed: false); + addTearDown(c.dispose); + expect( + c.read(explorationRepositoryProvider), + isA(), + ); + }); + + test('인증 → Remote repo 주입', () async { + final c = await container(authed: true); + addTearDown(c.dispose); + expect( + c.read(explorationRepositoryProvider), + isA(), + ); + }); + + test('게스트 정리: localDataSource.clearAll 직접 호출 — 순환참조 없음', () async { + // 8.1 회귀: repo 분기 추가 후에도 localDataSource 직접 경로가 동작. + final c = await container(authed: false); + addTearDown(c.dispose); + final ds = c.read(explorationLocalDataSourceProvider); + await ds.saveNodeState( + const ExplorationNodeState(nodeId: 'japan', isUnlocked: true, isCleared: true), + ); + await ds.clearAll(); + expect(ds.getAllStates(), isEmpty); + }); +} From f2a5009b3e30f96a40560a70923fab82396ec0e5 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 22:58:25 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix=20:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=C2=B7=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EC=8B=9C=20CircularDependencyError=20=EC=88=98=EC=A0=95=20#?= =?UTF-8?q?85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signOut: timerSessionRepositoryProvider(auth 의존) 대신 timerSessionLocalDataSourceProvider 직접 read 로 순환 제거 - signOut·withdraw: 수동 invalidate 제거, state=null 의 isAuthenticated 전이로 의존 Provider 자동 리빌드 - _clearGuestData: auth 의존 Provider invalidate 제거, auth 비의존인 badge 만 유지 (autoDispose 는 재진입 시 자동 리빌드) - orphan 된 today_stats_provider import 제거 --- .../presentation/providers/auth_provider.dart | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index f774f97..21b211e 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -24,7 +24,6 @@ import '../../../exploration/presentation/providers/exploration_provider.dart'; import '../../../fuel/presentation/providers/fuel_provider.dart'; import '../../../timer/presentation/providers/timer_provider.dart'; import '../../../timer/presentation/providers/timer_session_provider.dart'; -import '../../../timer/presentation/providers/today_stats_provider.dart'; import '../../../todo/presentation/providers/todo_provider.dart'; part 'auth_provider.g.dart'; @@ -328,10 +327,9 @@ class AuthNotifier extends _$AuthNotifier { final useCase = ref.read(signOutUseCaseProvider); await useCase.execute(); // Timer 로컬 캐시 삭제 (인증 → 게스트 전환 시 이전 세션 데이터 노출 방지) - await ref.read(timerSessionRepositoryProvider).clearAll(); - // 메모리 캐시 무효화 - ref.invalidate(timerSessionListNotifierProvider); - ref.invalidate(todayStatsNotifierProvider); + await ref.read(timerSessionLocalDataSourceProvider).clearAll(); + // state=null → isAuthenticated 재계산 → 의존 Provider 자동 리빌드. + // 수동 invalidate 는 authNotifier 자신을 watch 하는 Provider 라 순환 참조. state = const AsyncValue.data(null); } on FirebaseAuthException catch (e) { state = previous; @@ -350,7 +348,7 @@ class AuthNotifier extends _$AuthNotifier { // CircularDependencyError 가 발생한다. final clearTasks = Function()>[ () => ref.read(localTodoDataSourceProvider).clearAll(), - () => ref.read(timerSessionRepositoryProvider).clearAll(), + () => ref.read(timerSessionLocalDataSourceProvider).clearAll(), () => ref.read(fuelLocalDataSourceProvider).clearAll(), () => ref.read(explorationLocalDataSourceProvider).clearAll(), () => ref.read(badgeRepositoryProvider).clearAll(), @@ -364,13 +362,15 @@ class AuthNotifier extends _$AuthNotifier { } } - // 메모리 캐시 무효화 (예외 발생 불가) - ref.invalidate(timerSessionListNotifierProvider); - ref.invalidate(todayStatsNotifierProvider); - ref.invalidate(todoListNotifierProvider); - ref.invalidate(categoryListNotifierProvider); - ref.invalidate(fuelNotifierProvider); - ref.invalidate(explorationNotifierProvider); + // 메모리 캐시 갱신. + // timer/todo/fuel/exploration 등 auth 의존 Provider 는 authNotifier 를 조상으로 + // 가지므로 여기서 invalidate(또는 read/watch)하면 CircularDependencyError 가 난다 + // (debug assert). 대신: + // - autoDispose Provider(timer/todayStats/todo/category): 화면 재진입 시 비워진 + // 로컬 저장소로 자동 리빌드되므로 명시적 무효화 불필요. + // - keepAlive + auth 의존(fuel/exploration): 게스트 진입 시점엔 보통 미사용 상태라 + // 다음 watch 때 새로 빌드된다. + // - badge: auth 비의존 + keepAlive 라 명시적 invalidate 가 필요하고 안전하다. ref.invalidate(badgeNotifierProvider); } @@ -457,10 +457,9 @@ class AuthNotifier extends _$AuthNotifier { try { await ref.read(withdrawUseCaseProvider).execute(); // Timer 로컬 캐시 삭제 (인증 → 비로그인 전환 시 이전 세션 데이터 노출 방지) - await ref.read(timerSessionRepositoryProvider).clearAll(); - // 메모리 캐시 무효화 - ref.invalidate(timerSessionListNotifierProvider); - ref.invalidate(todayStatsNotifierProvider); + await ref.read(timerSessionLocalDataSourceProvider).clearAll(); + // state=null → isAuthenticated 재계산 → 의존 Provider 자동 리빌드. + // 수동 invalidate 는 authNotifier 자신을 watch 하는 Provider 라 순환 참조. state = const AsyncValue.data(null); } catch (e, stack) { state = previous; From f5d0c37668a2436938d110d0e8f6ec085db3dcb9 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 22:58:30 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix=20:=20=EB=B0=B0=EC=A7=80=20=ED=95=B4?= =?UTF-8?q?=EA=B8=88=20=EC=A7=80=EC=97=AD=20=EC=88=98=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20progress=20=EA=B8=B0=EB=B0=98=20=EA=B3=84=EC=82=B0=20#85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 행성마다 regionListNotifierProvider 를 read 하면 인증 모드에서 미방문 행성의 지역 API 가 연쇄 호출되는 문제를, 행성 응답에 내장된 progress.clearedChildren 합산으로 대체 --- .../badge/presentation/providers/badge_provider.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/features/badge/presentation/providers/badge_provider.dart b/lib/features/badge/presentation/providers/badge_provider.dart index a435081..715d676 100644 --- a/lib/features/badge/presentation/providers/badge_provider.dart +++ b/lib/features/badge/presentation/providers/badge_provider.dart @@ -62,13 +62,15 @@ class BadgeNotifier extends _$BadgeNotifier { const []; final unlockedPlanets = planets.where((p) => p.isUnlocked).length; - // 지역 해금 수 계산 + // 지역 해금 수 계산 — 행성 응답에 내장된 progress(=cleared) 합산. + // 지역은 해금 시 즉시 cleared 되므로 clearedChildren == 해금 지역 수. + // regionListNotifierProvider를 행성마다 read하면 인증 모드에서 미방문 + // 행성의 지역 API가 연쇄 호출되므로, 캐시된 progress를 사용한다. + final explorationRepo = ref.read(explorationRepositoryProvider); int unlockedRegions = 0; for (final planet in planets) { - final regions = - ref.read(regionListNotifierProvider(planet.id)).valueOrNull ?? - const []; - unlockedRegions += regions.where((r) => r.isUnlocked).length; + final progress = await explorationRepo.getProgress(planet.id); + unlockedRegions += progress.clearedChildren; } // 히든 배지: 현재 시간 체크 From ac1529e67f6cfea15cb9bf8081c187b9d16830cd Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Fri, 29 May 2026 23:00:13 +0900 Subject: [PATCH 14/14] fix : CI format --- .../exploration_remote_datasource.dart | 9 +- .../data/models/unlock_response_models.dart | 6 +- .../screens/exploration_detail_screen.dart | 5 +- .../screens/location_detail_screen.dart | 5 +- .../presentation/screens/explore_screen.dart | 9 +- ...ploration_remote_repository_impl_test.dart | 83 +++++++++++-------- .../exploration_provider_test.dart | 6 +- 7 files changed, 72 insertions(+), 51 deletions(-) diff --git a/lib/features/exploration/data/datasources/exploration_remote_datasource.dart b/lib/features/exploration/data/datasources/exploration_remote_datasource.dart index 67fe7eb..c54cbbb 100644 --- a/lib/features/exploration/data/datasources/exploration_remote_datasource.dart +++ b/lib/features/exploration/data/datasources/exploration_remote_datasource.dart @@ -20,15 +20,18 @@ abstract class ExplorationRemoteDataSource { /// 행성 하위 지역 목록 — 200 / 404 PLANET_NOT_FOUND @GET('/api/explorations/planets/{planetId}/regions') Future> getRegions( - @Path('planetId') String planetId); + @Path('planetId') String planetId, + ); /// 지역 해금 — 200 / 400 / 404 @POST('/api/explorations/regions/{regionId}/unlock') Future unlockRegion( - @Path('regionId') String regionId); + @Path('regionId') String regionId, + ); /// 행성 해금 — 200 / 400 / 404 @POST('/api/explorations/planets/{planetId}/unlock') Future unlockPlanet( - @Path('planetId') String planetId); + @Path('planetId') String planetId, + ); } diff --git a/lib/features/exploration/data/models/unlock_response_models.dart b/lib/features/exploration/data/models/unlock_response_models.dart index 4cc847c..18a3ef7 100644 --- a/lib/features/exploration/data/models/unlock_response_models.dart +++ b/lib/features/exploration/data/models/unlock_response_models.dart @@ -36,9 +36,9 @@ class RegionUnlockResponseModel with _$RegionUnlockResponseModel { _$RegionUnlockResponseModelFromJson(json); RegionUnlockResultEntity toEntity() => RegionUnlockResultEntity( - planetCleared: planetCleared, - currentFuel: currentFuel, - ); + planetCleared: planetCleared, + currentFuel: currentFuel, + ); } /// POST /api/explorations/planets/{planetId}/unlock 응답 diff --git a/lib/features/exploration/presentation/screens/exploration_detail_screen.dart b/lib/features/exploration/presentation/screens/exploration_detail_screen.dart index faf4ab3..6743bdd 100644 --- a/lib/features/exploration/presentation/screens/exploration_detail_screen.dart +++ b/lib/features/exploration/presentation/screens/exploration_detail_screen.dart @@ -100,8 +100,9 @@ class _ExplorationDetailScreenState Widget build(BuildContext context) { final planet = ref.watch( explorationNotifierProvider.select( - (async) => - async.valueOrNull?.where((p) => p.id == widget.planetId).firstOrNull, + (async) => async.valueOrNull + ?.where((p) => p.id == widget.planetId) + .firstOrNull, ), ); if (planet == null) { diff --git a/lib/features/exploration/presentation/screens/location_detail_screen.dart b/lib/features/exploration/presentation/screens/location_detail_screen.dart index f31e4fe..e4bb95b 100644 --- a/lib/features/exploration/presentation/screens/location_detail_screen.dart +++ b/lib/features/exploration/presentation/screens/location_detail_screen.dart @@ -106,8 +106,9 @@ class _LocationDetailScreenState extends ConsumerState { final currentFuel = currentFuelAsync.valueOrNull ?? 0; final planet = ref.watch( explorationNotifierProvider.select( - (async) => - async.valueOrNull?.where((p) => p.id == widget.planetId).firstOrNull, + (async) => async.valueOrNull + ?.where((p) => p.id == widget.planetId) + .firstOrNull, ), ); diff --git a/lib/features/explore/presentation/screens/explore_screen.dart b/lib/features/explore/presentation/screens/explore_screen.dart index cfda7da..99f27e2 100644 --- a/lib/features/explore/presentation/screens/explore_screen.dart +++ b/lib/features/explore/presentation/screens/explore_screen.dart @@ -83,11 +83,12 @@ class ExploreScreen extends ConsumerWidget { } } - final topInset = - MediaQuery.of(context).padding.top + kToolbarHeight; - final bottomInset = MediaQuery.of(context).padding.bottom + + final topInset = MediaQuery.of(context).padding.top + kToolbarHeight; + final bottomInset = + MediaQuery.of(context).padding.bottom + FloatingNavMetrics.totalHeight; - final mapHeight = topInset + + final mapHeight = + topInset + _mapTopPadding + (planets.length - 1) * _planetSpacing + _mapBottomPadding + diff --git a/test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart b/test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart index f4419d6..6ad99ab 100644 --- a/test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart +++ b/test/features/exploration/data/repositories/exploration_remote_repository_impl_test.dart @@ -32,20 +32,23 @@ void main() { progress: const ProgressResponseModel(clearedChildren: 1, totalChildren: 4), ); - DioException unlockError(String code, {int? requiredFuel, int? currentFuel}) => - DioException( - requestOptions: RequestOptions(path: '/api/explorations/regions/x/unlock'), - response: Response( - requestOptions: RequestOptions(path: '/x'), - statusCode: 400, - data: { - 'code': code, - 'message': 'msg', - if (requiredFuel != null) 'requiredFuel': requiredFuel, - if (currentFuel != null) 'currentFuel': currentFuel, - }, - ), - ); + DioException unlockError( + String code, { + int? requiredFuel, + int? currentFuel, + }) => DioException( + requestOptions: RequestOptions(path: '/api/explorations/regions/x/unlock'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 400, + data: { + 'code': code, + 'message': 'msg', + if (requiredFuel != null) 'requiredFuel': requiredFuel, + if (currentFuel != null) 'currentFuel': currentFuel, + }, + ), + ); test('getPlanets — 매핑 + getProgress 캐시 사용', () async { when(() => remote.getPlanets()).thenAnswer((_) async => [planet('mars')]); @@ -60,33 +63,41 @@ void main() { verify(() => remote.getPlanets()).called(1); // 단 1회 }); - test('unlockRegion INSUFFICIENT_FUEL → fuel InsufficientFuelException', () async { - when(() => remote.unlockRegion('seoul')).thenThrow( - unlockError('INSUFFICIENT_FUEL', requiredFuel: 10, currentFuel: 3), - ); + test( + 'unlockRegion INSUFFICIENT_FUEL → fuel InsufficientFuelException', + () async { + when(() => remote.unlockRegion('seoul')).thenThrow( + unlockError('INSUFFICIENT_FUEL', requiredFuel: 10, currentFuel: 3), + ); - expect( - () => repo.unlockRegion('seoul'), - throwsA( - isA() - .having((e) => e.requiredAmount, 'requiredAmount', 10) - .having((e) => e.available, 'available', 3), - ), - ); - }); + expect( + () => repo.unlockRegion('seoul'), + throwsA( + isA() + .having((e) => e.requiredAmount, 'requiredAmount', 10) + .having((e) => e.available, 'available', 3), + ), + ); + }, + ); - test('unlockRegion ALREADY_UNLOCKED → NodeAlreadyUnlockedException', () async { - when(() => remote.unlockRegion('seoul')) - .thenThrow(unlockError('ALREADY_UNLOCKED')); - expect( - () => repo.unlockRegion('seoul'), - throwsA(isA()), - ); - }); + test( + 'unlockRegion ALREADY_UNLOCKED → NodeAlreadyUnlockedException', + () async { + when( + () => remote.unlockRegion('seoul'), + ).thenThrow(unlockError('ALREADY_UNLOCKED')); + expect( + () => repo.unlockRegion('seoul'), + throwsA(isA()), + ); + }, + ); test('unlockPlanet 성공 → 결과 엔티티', () async { when(() => remote.unlockPlanet('mars')).thenAnswer( - (_) async => const PlanetUnlockResponseModel(fuelConsumed: 10, currentFuel: 40), + (_) async => + const PlanetUnlockResponseModel(fuelConsumed: 10, currentFuel: 40), ); final result = await repo.unlockPlanet('mars'); expect(result.currentFuel, 40); diff --git a/test/features/exploration/presentation/exploration_provider_test.dart b/test/features/exploration/presentation/exploration_provider_test.dart index d9aa12a..b324d62 100644 --- a/test/features/exploration/presentation/exploration_provider_test.dart +++ b/test/features/exploration/presentation/exploration_provider_test.dart @@ -46,7 +46,11 @@ void main() { addTearDown(c.dispose); final ds = c.read(explorationLocalDataSourceProvider); await ds.saveNodeState( - const ExplorationNodeState(nodeId: 'japan', isUnlocked: true, isCleared: true), + const ExplorationNodeState( + nodeId: 'japan', + isUnlocked: true, + isCleared: true, + ), ); await ds.clearAll(); expect(ds.getAllStates(), isEmpty);