Skip to content

Comments

feat: push 알림 기능#87

Open
spacefoul wants to merge 8 commits intomainfrom
kyh-ios
Open

feat: push 알림 기능#87
spacefoul wants to merge 8 commits intomainfrom
kyh-ios

Conversation

@spacefoul
Copy link
Collaborator

@spacefoul spacefoul commented Sep 9, 2025

📋 개요

변경 사항 및 관련 이슈에 대해 간단하게 작성해주세요.

관련 이슈:

  • Resolves: #(Issue Number)

🏷️ PR 유형

어떤 변경 사항이 있나요?

📱 기능 관련

  • 새로운 기능 추가 (투자 시뮬레이션, 학습 콘텐츠 등)
  • 기존 기능 개선 및 수정
  • 버그 수정

🎨 UI/UX 관련

  • 사용자 인터페이스 디자인 변경
  • 사용자 경험(UX) 개선
  • 반응형 디자인 수정

🔧 개발 환경 관련

  • 코드 리팩토링
  • 성능 최적화
  • 코드에 영향을 주지 않는 변경사항 (오타 수정, 포맷팅 등)
  • 테스트 추가 및 수정
  • 빌드 설정 또는 패키지 의존성 변경

📚 문서화

  • 주석 추가 및 수정
  • README 또는 문서 수정
  • API 문서 업데이트

🗂️ 구조 변경

  • 파일 또는 폴더명 수정
  • 파일 또는 폴더 삭제
  • 프로젝트 구조 변경

📸 스크린샷 및 데모

변경된 UI나 새로운 기능이 있다면 스크린샷을 첨부해주세요.

Before (변경 전)

After (변경 후)

📱 테스트 환경

  • iOS Simulator
  • Android Emulator

📝 상세 변경 내용

구체적인 변경 사항을 설명해주세요.

추가된 내용

수정된 내용

삭제된 내용


🔗 관련 파일

주요 변경된 파일들을 나열해주세요.

  • src/screens/
  • src/components/
  • src/services/
  • src/utils/
  • src/types/
  • 기타:

🚧 추가 작업 계획

이 PR 이후에 추가로 진행할 작업이 있다면 작성해주세요.

현재 제한사항

향후 개선 계획

후속 작업


💬 리뷰어에게

리뷰어가 특별히 주의 깊게 봐야 할 부분이나 질문이 있다면 작성해주세요.


📚 참고 자료

관련 문서, 디자인, 또는 참고한 자료가 있다면 링크를 첨부해주세요.

Summary by CodeRabbit

  • New Features

    • 푸시 알림: 권한 흐름 강화, 포그라운드 배너·사운드·배지 및 환영 알림
    • 챗봇: 완전한 대화 UI, 제안 패널·타이핑 표시·향상된 입력 UX
    • 회원 프로필: MBTI 별명 표시 및 생일 D-day
  • Enhancements

    • 인증/가입: 키보드 대응 애니메이션, 약관 확장/접기 UI, 비밀번호 재설정 2단계 흐름
    • 학습/가이드/스터디: 안전영역 반영, 포커스 기반 재시도 UI 및 향상된 마크다운 렌더링
    • UI/접근성: 탭바·패딩 조정, 룰렛·채팅·로그인 등 전반적 시각/입력 개선, 종목 기호 ▲/▼ 적용
  • Chores

    • 앱 메타(앱명 한국어화·소유자·프로젝트 ID·iOS 빌드번호), 패키지 스크립트/의존성 업데이트, 디바이스 목록 초기화 및 기타 설정 파일 업데이트

@coderabbitai
Copy link

coderabbitai bot commented Sep 9, 2025

Walkthrough

iOS 실기기 중심의 Expo 푸시 알림 초기화(권한 검사·리스너 등록·Expo 푸시 토큰 획득) 추가와 앱 초기화·안전영역·키보드 대응, 다수 화면(UI/애니메이션/에러·재시도) 리팩터 및 프로젝트 메타데이터·스크립트 업데이트입니다.

Changes

Cohort / File(s) Change Summary
Push notifications & app init
App.js, src/services/PushNotificationService.ios.js
iOS 실기기 전용 지연 초기화, 권한 확인/요청, 전역 알림 핸들러, 리스너 등록·cleanup 반환, registerExpoPushToken/unregisterPushToken 도입, 기존 서버종속 토큰 흐름 제거 및 시그니처 변경.
Auth / Onboarding screens
src/screens/Auth/LoginScreen.js, src/screens/Auth/SignUp1Screen.js, src/screens/Auth/SignUp2Screen.js, src/screens/Auth/FindPasswordScreen.js
Login: 키보드 인식·애니메이션·로딩·비밀번호 가시성 토글. SignUp1: 데이터 기반 아코디언 약관·확장·동의 로직. SignUp2/FindPassword: 키보드 처리·다단계 인증·코드 입력·재전송·검증 흐름 추가.
Navigation / Main / Tab UI
src/navigation/MainTab.js, src/screens/Main/MainScreen.js, src/screens/Main/StockDetail.js
탭바 높이·paddingBottom을 안전영역 기반으로 동적 계산(플랫폼 분기), 일부 패딩/마진 조정, StockDetail의 변동 기호를 ▲/▼로 변경.
Guide / Study flow
src/screens/Guide/GuideScreen.js, .../GuideLevel1.js, .../GuideLevel2.js, .../GuideLevel3.js, .../StudyScreen.js
Safe-area 적용, useFocusEffect로 진행도 로드, 토큰 재발급 검사·에러·재시도 UI 추가, 챕터 클릭성 로직 정리 및 레이아웃·스타일 대규모 개편.
Chatbot & interactive UIs
src/screens/Chatbot/ChatbotScreen.js, src/screens/MyPage/RouletteScreen.js
Chatbot: 완전한 채팅 UI(메시지 모델, 제안 패널, 타이핑 인디케이터, 애니메이션). Roulette: SVG/그라데이션 휠·애니메이션·보상 로직 및 에러 처리 강화.
MyPage / Profile
src/screens/MyPage/MyPageScreen.js
MBTI 별칭 비동기 조회·표시, 생일 D-day/이메일 유틸 추가, 로그아웃·탈퇴·푸시 정리 흐름 보강, 프로필·메뉴 UI 재구성.
Utilities & helpers
src/utils/point.js
increaseBalance 에러 처리 변경: 예외 대신 상태별 문자열 코드로 reject 및 상세 로그 추가.
Project metadata & config
app.json, app.config.js, package.json, .expo/devices.json
app.json 앱명/owner/ios.buildNumber/extra.eas.projectId 갱신. app.config.js 기본 export 제거(파일 삭제). package.json 스크립트(expo run:*), 의존성 범위 조정 및 overrides 제거. .expo/devices.jsondevices 배열을 여러 장치 항목으로 채움.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant App
  participant Platform as Device/OS
  participant Notifications
  participant PushSvc as PushNotificationService

  note over App: 지연된 알림 초기화 흐름 (iOS 실기기 전용)
  User->>App: 앱 실행
  App->>Platform: Platform/isDevice 검사
  alt iOS 실기기
    App->>Notifications: getPermissionsAsync()
    alt 권한 없음
      App->>Notifications: requestPermissionsAsync()
      alt 거부
        App-->>User: 알림 초기화 중단(로그)
      else 허용
        App->>PushSvc: setupNotificationListeners()
        App->>PushSvc: registerExpoPushToken()
        PushSvc->>Notifications: getExpoPushTokenAsync({projectId})
        Notifications-->>PushSvc: expoPushToken or error
        PushSvc-->>App: 결과 반환
      end
    else 이미 허용
      App->>PushSvc: setupNotificationListeners()
      App->>PushSvc: registerExpoPushToken()
    end
  else (시뮬레이터/Android/기타)
    App-->>User: 알림 초기화 스킵
  end
  note over App,PushSvc: 언마운트 시 cleanup 호출 (리스너 해제)
Loading
sequenceDiagram
  autonumber
  actor User
  participant ChatUI as ChatbotScreen
  participant BotAPI as chatbotReply

  User->>ChatUI: 메시지 입력 및 전송
  ChatUI->>ChatUI: 사용자 메시지 추가 + 로딩 플레이스홀더
  ChatUI->>BotAPI: chatbotReply(질문)
  alt 응답 성공
    BotAPI-->>ChatUI: 답변 텍스트
    ChatUI->>ChatUI: 로딩 대체 및 봇 메시지 추가
  else 오류
    BotAPI-->>ChatUI: 오류
    ChatUI->>ChatUI: 폴백 메시지 추가, 로딩 제거
  end
  ChatUI->>ChatUI: 뷰/스크롤 갱신
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description Check ⚠️ Warning 설명은 템플릿 항목별로 섹션은 존재하지만 “개요”와 “관련 이슈”, “상세 변경 내용” 등이 모두 플레이스홀더 상태로 실제 내용을 기재하지 않아 필요한 정보가 거의 누락된 상태입니다. 개요에 실제 변경 사항 요약과 관련 이슈 번호를 추가하고 상세 변경 내용 섹션에 무엇이 추가·수정·삭제되었는지 구체적으로 작성한 뒤 관련 파일 리스트 및 스크린샷(필요 시)을 보강해 주세요.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed 제목 “feat: push 알림 기능”은 이번 변경의 핵심인 푸시 알림 기능 추가를 간결하게 요약하고 있어 주요 변경 사항을 명확하게 전달합니다.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kyh-ios

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
_untracked_now.txt (1)

1-33: 로컬 캐시/빌드 산출물 경로 목록 커밋됨(_untracked_now.txt) — 제거 및 ignore 필요

Expo 캐시, Xcode 로그 등 로컬 전용 정보입니다. 파일 자체도 불필요합니다. 삭제하고 캐시·로그 패턴을 .gitignore에 추가하세요.

제거(diff):

--- a/_untracked_now.txt
+++ /dev/null
@@ -1,34 +0,0 @@
-...

권장 .gitignore 추가:

+# Expo / Xcode caches
+/.expo/
+.expo/*
+.expo/**/*
+*.log
+ios/*.xcworkspace/*
+ios/*.xcodeproj/project.xcworkspace/*
+ios/*.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
src/utils/token.js (2)

66-87: 민감정보(이메일/비밀번호) AsyncStorage 저장 및 자동 로그인 사용 — 보안상 중대 위험

평문 자격증명을 영구 저장 후 자동 로그인은 탈취 시 계정 장악 위험이 큽니다. 일반적으로 비밀번호는 저장하지 않습니다. Refresh 토큰 갱신 실패 시 로그인 화면으로 유도하는 흐름으로 제한하세요. 꼭 필요하다면 expo-secure-store 등 OS 보안 영역에 최소 기간 저장+사용자 동의가 필요합니다.

간소 대안(diff: 자동 로그인 제거):

-const loginForNewToken = async (navigation) => {
-  try {
-    const email = await AsyncStorage.getItem("userEmail");
-    const password = await AsyncStorage.getItem("userPassword");
-    ...
-    const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.LOGIN}`, {
-      method: "POST",
-      headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ email, password }),
-    });
-    ...
-  } catch (error) {
-    console.error("❌ 새 토큰 발급 실패:", error);
-    if (navigation) {
-      navigation.navigate("Login");
-    }
-    return null;
-  }
-};
+const loginForNewToken = async (navigation) => {
+  if (navigation) navigation.navigate("Login");
+  return null;
+};

선호 대안(보안 저장 사용) 예시:

// expo-secure-store 사용 예시
import * as SecureStore from "expo-secure-store";
// 저장 시: await SecureStore.setItemAsync("userPassword", password, { requireAuthentication: true });

26-33: /sessions 엔드포인트로의 refresh 토큰 전송 계약 확인 및 전용 토큰 엔드포인트 사용 검토
세션 생성/삭제용인 /sessions에 refresh 토큰을 POST하는 방식은 OAuth2 표준이나 주요 구현에서 권장되지 않습니다. OAuth2 표준은 /token·/oauth/token·/api/token/refresh 같은 전용 엔드포인트로 grant_type=refresh_token 형태의 POST를 사용하도록 규정하고 있으므로, 백엔드가 /sessions에서 refresh 파라미터를 처리하는지 확인하고 일치하지 않을 경우 전용 엔드포인트로 변경해 401 루프를 방지하세요.

src/services/PushNotificationService.ios.js.backup (1)

1-129: src/services/PushNotificationService.ios.js.backup 파일 삭제
플랫폼 확장(.ios.js) 파일만 유지하고, 백업 파일은 혼동을 유발하므로 커밋에서 제거해주세요.

🧹 Nitpick comments (21)
src/utils/apiConfig.js (2)

62-66: 엔드포인트 슬래시 일관성 — 푸시 토큰 경로에 트레일링 슬래시 추가 제안

대부분의 엔드포인트가 “/”로 끝나는데 PUSH_TOKENS만 미포함입니다. DRF 등 일부 백엔드는 슬래시 유무에 민감하며 301/308 리다이렉트·CORS 문제를 유발할 수 있습니다. 프로젝트 규칙에 맞춰 일관화하세요.

적용 제안(diff):

-  PUSH_TOKENS: "api/push-tokens",
+  PUSH_TOKENS: "api/push-tokens/",

위 백엔드가 슬래시 필수인지 확인 부탁드립니다.


69-93: 공통 fetch 유틸은 좋음 — 다만 에러 정보 보존 고려

현재 !response.ok 시 Error로 래핑하면서 status 외 정보가 손실됩니다. 호출측에서 상태코드/본문을 함께 보고 싶다면 body 텍스트/JSON을 포함해 반환하는 형태를 고려해보세요.

src/utils/token.js (1)

160-161: 오탈자

주석 “정맇” → “정리”.

적용 제안(diff):

-    await AsyncStorage.removeItem("hasCompletedTutorial"); // 튜토리얼 여부도 정맇
+    await AsyncStorage.removeItem("hasCompletedTutorial"); // 튜토리얼 여부도 정리
src/screens/Main/SearchScreen.js (2)

44-45: 상태 직후 로그는 이전 값 출력 가능

setAutoCompleteResults 직후의 autoCompleteResults는 업데이트 전 값일 수 있습니다. 로그는 data를 직접 출력하거나 useEffect로 상태 변화를 구독해 찍으세요.


80-91: 디바운싱 중 중복 요청 취소 없음 — 레이스로 이전 결과가 덮일 수 있음

AbortController로 이전 fetch를 취소하는 것을 권장합니다.

예시 스케치:

const acRef = useRef();
useEffect(() => {
  if (acRef.current) acRef.current.abort();
  const ac = new AbortController(); acRef.current = ac;
  const t = setTimeout(() => searchQuery && fetchAutoComplete(searchQuery, ac.signal), 800);
  return () => { clearTimeout(t); ac.abort(); };
}, [searchQuery]);
package.json (1)

7-9: run 스크립트로 대체 시 개발 사이클 저하 — dev-client와 개발 서버 스크립트 분리 권장

expo run:*는 네이티브 빌드를 유발하므로 반복 개발에 비효율적입니다. dev-client를 쓰더라도 expo start --dev-client 스크립트를 별도로 두세요.

적용 제안(diff):

   "scripts": {
     "start": "expo start",
-    "android": "expo run:android",
-    "ios": "expo run:ios",
+    "android": "expo start --android",
+    "ios": "expo start --ios",
+    "dev": "expo start --dev-client",
+    "android:run": "expo run:android",
+    "ios:run": "expo run:ios",
     "web": "expo start --web"
   },
src/screens/MyPage/ChangePasswordScreen.js (2)

49-58: 엔드포인트 상수화 및 URL 구성 일관화

직접 문자열 연결 대신 API_ENDPOINTS에 상수로 추가해 재사용성을 높이세요. 또한 공통 유틸(fetchAPI/fetchAuthAPI)로 통일하면 로깅·에러 처리가 일관됩니다.

적용 제안(diff — 본 파일):

-      const response = await fetchWithAuth(
-        `${API_BASE_URL}users/change_password/`,
+      const response = await fetchWithAuth(
+        `${API_BASE_URL}${API_ENDPOINTS.CHANGE_PASSWORD}`,
         {
           method: "PUT",
           body: JSON.stringify({
             current_password: currentPassword,
             new_password: newPassword,
           }),
         },
         navigation
       );

추가 제안(apiConfig.js에 상수 추가):

   // 사용자 관련
   USER_ME: "users/me/",
   USER_ACCOUNT: "users/account/",
   USER_DELETE: "users/delete/",
+  CHANGE_PASSWORD: "users/change_password/",

60-69: 비JSON 응답 대비 처리 보강 제안

서버 오류로 HTML/텍스트 응답이 오는 경우 response.json()에서 예외가 납니다. Content-Type 확인 후 파싱을 분기하면 오류 메시지를 더 안전하게 노출할 수 있습니다.

src/screens/Guide/TypeResultScreen.js (3)

48-55: 선행 슬래시 제거는 합리적입니다. 다만 안전한 URL 조합으로 교체 권장

API_BASE_URL이 슬래시로 끝난다는 전제에 의존합니다. new URL을 사용해 안전하게 결합하면 회귀를 막을 수 있습니다.

-        `${API_BASE_URL}mbti/result/detail/`,
+        new URL("mbti/result/detail/", API_BASE_URL).toString(),

90-97: 동일: 안전한 URL 결합 적용 제안

위와 동일한 맥락입니다.

-          `${API_BASE_URL}mbti/result/recommendations/`,
+          new URL("mbti/result/recommendations/", API_BASE_URL).toString(),

44-45: 개발 로그는 DEV 가드로 제한

서버 응답 원문·파싱 로그는 민감 정보가 섞일 수 있어 릴리스 빌드에서 비활성화하세요.

-      console.log("MBTI 결과와 추천 정보를 가져오는 중...");
+      if (__DEV__) console.log("MBTI 결과와 추천 정보를 가져오는 중...");
@@
-      console.log("결과 응답 원본:", resultText);
+      if (__DEV__) console.log("결과 응답 원본:", resultText);
@@
-        console.log("추천 응답 원본:", recText);
+        if (__DEV__) console.log("추천 응답 원본:", recText);

Also applies to: 61-63, 103-105

src/services/PushNotificationService.ios.js.backup (3)

41-47: projectId를 계산만 하고 사용하지 않음

현재 로직에서는 계산된 projectId가 사용되지 않습니다. 위 수정안과 함께 실제로 getExpoPushTokenAsync에 전달하세요.


6-6: 불필요한 종속성 제거 제안

@react-native-firebase/messaging가 실제 사용되지 않게 된다면 의존성/초기화를 제거해 번들 크기와 초기화 비용을 줄이세요.

-import messaging from '@react-native-firebase/messaging';

73-128: Promise 체인 → async/await로 단순화

예외 흐름과 가독성 향상을 위해 async 함수로 재작성하세요.

-export function registerExpoPushToken(/* navigation? */) {
-  return new Promise((resolve) => {
-    try {
-      if (Platform.OS !== "ios") {
-        return resolve({ success: false, error: "iOS only (.ios.js)" });
-      }
-      if (!Device.isDevice) {
-        return resolve({ success: false, error: "실기기 필요" });
-      }
-
-      // 1) 권한 확인
-      Notifications.getPermissionsAsync()
-        .then((perm) => {
-          const existing = perm?.status;
-          console.log("[Push] permissions(existing):", existing, perm);
-
-          if (existing === "granted") return "granted";
-          // 권한 요청
-          return Notifications.requestPermissionsAsync({
-            ios: { allowAlert: true, allowBadge: true, allowSound: true },
-          }).then((r) => r?.status || "undetermined");
-        })
-        .then((finalStatus) => {
-          console.log("[Push] permission:", finalStatus);
-          if (finalStatus !== "granted") {
-            throw new Error("알림 권한 거부됨");
-          }
-
-          // 2) FCM 토큰을 Expo Push Token으로 변환
-          return convertFCMToExpoPushToken();
-        })
-        .then((result) => {
-          if (result.success) {
-            console.log("📢 [Push] ExpoPushToken 발급됨:", result.expoPushToken);
-            resolve({ 
-              success: true, 
-              expoPushToken: result.expoPushToken,
-              fcmToken: result.fcmToken
-            });
-          } else {
-            throw new Error(result.error);
-          }
-        })
-        .catch((err) => {
-          const msg = err?.message || String(err);
-          console.warn("[Push][ERR] registerExpoPushToken:", msg);
-          resolve({ success: false, error: msg });
-        });
-    } catch (e) {
-      const msg = e?.message || String(e);
-      console.warn("[Push][ERR] registerExpoPushToken(sync):", msg);
-      resolve({ success: false, error: msg });
-    }
-  });
-}
+export async function registerExpoPushToken() {
+  try {
+    if (Platform.OS !== "ios") return { success: false, error: "iOS only (.ios.js)" };
+    if (!Device.isDevice) return { success: false, error: "실기기 필요" };
+
+    const perm = await Notifications.getPermissionsAsync();
+    let status = perm?.status;
+    if (status !== "granted") {
+      const req = await Notifications.requestPermissionsAsync({
+        ios: { allowAlert: true, allowBadge: true, allowSound: true },
+      });
+      status = req?.status ?? "undetermined";
+    }
+    if (status !== "granted") throw new Error("알림 권한 거부됨");
+
+    // projectId 조회 후 실제 Expo Push Token 발급
+    const projectId =
+      (Constants.easConfig && Constants.easConfig.projectId) ||
+      (Constants.expoConfig &&
+        Constants.expoConfig.extra &&
+        Constants.expoConfig.extra.eas &&
+        Constants.expoConfig.extra.eas.projectId);
+    if (!projectId) throw new Error("EAS projectId 없음");
+
+    const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId });
+    console.log("📢 [Push] ExpoPushToken 발급됨:", expoPushToken);
+    return { success: true, expoPushToken };
+  } catch (e) {
+    const msg = e?.message || String(e);
+    console.warn("[Push][ERR] registerExpoPushToken:", msg);
+    return { success: false, error: msg };
+  }
+}
app.json (1)

23-23: iOS 백그라운드 원격 푸시 활성화 방식 정리 제안

  • Info.plist에 직접 UIBackgroundModes: ["remote-notification"]를 넣으셨는데, 같은 설정을 플러그인에서 관리할 수도 있습니다. 한 곳에서만 관리하면 혼선이 줄어듭니다.

다음처럼 플러그인에 위임하고 Info.plist 키는 제거하는 것을 권장합니다:

-      "infoPlist": {
-        "UIBackgroundModes": ["remote-notification"],
-        "ITSAppUsesNonExemptEncryption": false
-      }
+      "infoPlist": {
+        "ITSAppUsesNonExemptEncryption": false
+      }

그리고 플러그인 설정에 옵션 추가:

-      ["expo-notifications", { "icon": "./src/assets/icons/star-filled.png", "color": "#F074BA" }]
+      ["expo-notifications", {
+        "icon": "./src/assets/icons/star-filled.png",
+        "color": "#F074BA",
+        "enableBackgroundRemoteNotifications": true
+      }]
App.js (3)

85-109: 과도한 로깅(PII/성능) — 개발 모드로 제한 권장

  • Notification/Response 전체 객체를 JSON.stringify로 로그합니다. 프로덕션 로그로 유출/성능 이슈 소지가 있습니다.
-      // 추가: 로컬 로그 리스너
-      try {
+      // 추가: 로컬 로그 리스너 (DEV 전용)
+      if (__DEV__) try {
         if (!localListeners.current.received) {
           localListeners.current.received =
             Notifications.addNotificationReceivedListener((n) => {
               try {
                 console.log("[Push][recv] (fg) notification:", JSON.stringify(n, null, 2));
               } catch {
                 console.log("[Push][recv] (fg) notification received");
               }
             });
         }
         if (!localListeners.current.response) {
           localListeners.current.response =
             Notifications.addNotificationResponseReceivedListener((r) => {
               try {
                 console.log("[Push][tap] response:", JSON.stringify(r, null, 2));
               } catch {
                 console.log("[Push][tap] notification tapped");
               }
             });
         }
-      } catch (e) {
+      } catch (e) {
         console.log("[Push][ERR] add listeners failed:", e?.message || e);
-      }
+      }

145-147: Navigation 준비 대기는 onReady로 대체 권장

  • setTimeout(…, 500) 대신 NavigationContaineronReady에서 초기화를 호출하면 더 결정적입니다.

예시:

-    timer = setTimeout(initializeNotifications, 500);
+    // no-op: onReady에서 호출하도록 변경

그리고 JSX:

-  <NavigationContainer
-    ref={(r) => {
-      navigationRef.current = r;
-      if (r) console.log("[Nav] navigationRef ready");
-    }}
-  >
+  <NavigationContainer
+    ref={(r) => (navigationRef.current = r)}
+    onReady={() => {
+      console.log("[Nav] ready");
+      initializeNotifications();
+    }}
+  >

57-60: 미사용 상태 확인(navigationRef not ready 로그)

  • 네비 객체는 실제로 사용되지 않습니다. 불필요한 분기/로그는 정리해도 좋습니다.
src/services/PushNotificationService.ios.js (3)

50-55: 토큰 로그는 개발 모드로 제한

  • Expo Push Token은 민감 식별자입니다. 프로덕션 로그에서 제외하세요.
-    console.log("📢 [Push] ExpoPushToken:", expoPushToken);
+    if (__DEV__) console.log("📢 [Push] ExpoPushToken:", expoPushToken);

15-18: 구독 해제는 remove() 사용으로 단순화 가능

  • Expo가 반환하는 Subscription은 remove()를 제공합니다. 가독성 측면에서 간결합니다. (docs.expo.dev)
   return () => {
-    Notifications.removeNotificationSubscription(recvSub);
-    Notifications.removeNotificationSubscription(respSub);
+    recvSub.remove?.();
+    respSub.remove?.();
   };

7-13: iOS 전용 리스너에서 내비게이션 연동 계획

  • 현재는 로그 전용입니다. 알림 탭 시 화면 전환이 필요하면 Notifications.getLastNotificationResponseAsync()와 연동하는 접근을 고려하세요(콜드스타트 처리). 구현은 App 레이어가 적합합니다. (docs.expo.dev)
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d04900f and 9e62125.

⛔ Files ignored due to path filters (3)
  • .expo/web/cache/production/images/iconsuniversal-icon/iconsuniversal-icon-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-cover-#ffffff/App-Icon-1024x1024@1x.png is excluded by !**/*.png
  • .expo/web/cache/production/images/splash-ios/splash-ios-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-contain/icon_undefined.png is excluded by !**/*.png
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (15)
  • .expo/devices.json (1 hunks)
  • App.js (1 hunks)
  • _modified_tracked.txt (1 hunks)
  • _now.status (1 hunks)
  • _untracked_now.txt (1 hunks)
  • app.config.js (0 hunks)
  • app.json (3 hunks)
  • package.json (2 hunks)
  • src/screens/Guide/TypeResultScreen.js (2 hunks)
  • src/screens/Main/SearchScreen.js (2 hunks)
  • src/screens/MyPage/ChangePasswordScreen.js (1 hunks)
  • src/services/PushNotificationService.ios.js (1 hunks)
  • src/services/PushNotificationService.ios.js.backup (1 hunks)
  • src/utils/apiConfig.js (1 hunks)
  • src/utils/token.js (1 hunks)
💤 Files with no reviewable changes (1)
  • app.config.js
🧰 Additional context used
🧬 Code graph analysis (9)
src/screens/Guide/TypeResultScreen.js (3)
src/utils/apiConfig.js (2)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
src/utils/mbtiType.js (1)
  • navigation (5-50)
src/utils/change.js (1)
  • stock_code (5-34)
_modified_tracked.txt (1)
src/services/PushNotificationService.js (2)
  • Platform (27-42)
  • navigation (45-99)
_now.status (1)
src/services/PushNotificationService.js (1)
  • Platform (27-42)
src/screens/MyPage/ChangePasswordScreen.js (2)
src/utils/apiConfig.js (2)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
src/screens/Auth/ResetPasswordScreen.js (2)
  • route (13-162)
  • resetToken (21-87)
App.js (2)
src/services/PushNotificationService.ios.js (4)
  • Notifications (33-35)
  • existing (30-30)
  • setupNotificationListeners (7-19)
  • registerExpoPushToken (39-60)
src/services/PushNotificationService.js (6)
  • Notifications (33-33)
  • setupNotificationListeners (193-220)
  • setupNotificationListeners (193-220)
  • navigation (213-246)
  • navigation (45-99)
  • Platform (27-42)
src/services/PushNotificationService.ios.js.backup (1)
src/services/PushNotificationService.js (3)
  • Platform (27-42)
  • navigation (45-99)
  • navigation (213-246)
src/utils/apiConfig.js (1)
src/utils/hantuToken.js (1)
  • console (208-231)
src/screens/Main/SearchScreen.js (2)
src/utils/apiConfig.js (2)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
src/screens/Main/MainScreen.js (1)
  • stock (88-141)
src/services/PushNotificationService.ios.js (1)
src/services/PushNotificationService.js (3)
  • navigation (45-99)
  • navigation (213-246)
  • navigation (162-210)
🔇 Additional comments (7)
package.json (1)

18-24: Expo 패키지 버전 고정 및 호환성 검증 필요
Wildcard(*) 사용 시 SDK 53 호환성/재현성 리스크 발생.
npx expo install로 아래 패키드를 SDK 53 호환 버전으로 고정하고, react@19.0.0 및 react-native@0.79.5 호환성도 함께 검증하세요.

  • expo-constants
  • expo-font
  • expo-linking
  • expo-splash-screen
  • expo-dev-client
  • expo-notifications
app.json (4)

4-4: slug 변경 의도 확인

  • slug: "rncourse"로 변경되었습니다. OTA 채널/딥링크/Dev Client 워크플로에 간접 영향이 있을 수 있습니다. 의도된 리브랜딩인지 확인 바랍니다.

20-20: iOS buildNumber 증분 확인

  • buildNumber: "10"은 이전 스토어 배포본보다 커야 제출이 가능합니다. 직전 배포값 대비 증분 여부 확인 바랍니다.

37-37: Android 알림 아이콘 규격 확인

  • expo-notificationsicon은 96x96, “순백 단색(white-only) + 투명 배경”을 권장합니다. 컬러 아이콘 사용 시 트레이에서 흐리게 보이거나 잘릴 수 있습니다. 현재 star-filled.png가 해당 가이드를 충족하는지 확인해 주세요. 필요하면 전용 white-only 아이콘을 두는 것을 권장합니다.

5-7: EAS projectId/owner 설정 일치 여부 확인
app.json의 owner(“yehyeon”) 및 projectId(“03400e3a-d687-464c-9ef2-13322cf99fcb”)가 실제 EAS 프로젝트의 값과 동일한지 Expo Dashboard에서 반드시 검증하세요. 불일치 시 getExpoPushTokenAsync 호출이 실패할 수 있습니다.

App.js (1)

19-31: 플래그 지원 확인됨
설치된 expo-notifications 버전(~0.31.4)은 shouldShowBanner/shouldShowList 옵션을 지원하므로 추가 조치가 필요 없습니다.

src/services/PushNotificationService.ios.js (1)

47-51: projectId 소스와 앱 설정 싱크 확인

  • getProjectId()Constants.easConfig 또는 Constants.expoConfig.extra.eas.projectId를 읽습니다. app.json의 extra.eas.projectId와 실제 EAS 프로젝트가 일치하는지(이 PR의 app.json 변경과) 다시 한 번 교차 확인 바랍니다. 불일치 시 토큰 발급 실패합니다. (docs.expo.dev)

Comment on lines 1 to 5
.expo/devices.json
App.js
package-lock.json
package.json
src/services/PushNotificationService.ios.js
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

로컬 상태 스냅샷 파일 커밋됨(_modified_tracked.txt) — 제거 권장

개발 편의를 위한 임시 목록 파일입니다. 저장소에 남기면 히스토리 오염과 혼동을 유발합니다. 파일을 삭제하고 생성 경로를 .gitignore로 제외하세요.

제거(diff):

--- a/_modified_tracked.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-.expo/devices.json
-App.js
-package-lock.json
-package.json
-src/services/PushNotificationService.ios.js
-
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.expo/devices.json
App.js
package-lock.json
package.json
src/services/PushNotificationService.ios.js
🤖 Prompt for AI Agents
In _modified_tracked.txt around lines 1 to 5: this temporary local snapshot file
has been committed and should be removed; delete the file from the repo (git rm
_modified_tracked.txt and commit the deletion), add the filename or its
generation path to .gitignore so it won’t be re-added (or use git rm --cached if
you want to keep a local copy), and commit the updated .gitignore; ensure no
sensitive data remains in history—if needed, remove from history with a rewrite
tool (e.g., git-filter-repo) and notify the team.

_now.status Outdated
Comment on lines 1 to 11
M .expo/devices.json
M App.js
M package-lock.json
M package.json
M src/services/PushNotificationService.ios.js
?? .expo/prebuild/
?? .expo/web/
?? .expo/xcodebuild-error.log
?? .expo/xcodebuild.log
?? _now.status
?? ios/
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

작업 산출물(_now.status, .expo/ 로그 등)이 PR에 포함됨 — 제거 및 .gitignore 추가 권장*

이 파일과 .expo/xcodebuild*.log 같은 빌드/로컬 상태 파일은 버전 관리 대상이 아닙니다. PR에서 제거하고 .gitignore에 패턴을 추가해주세요.

.gitignore 예시:

+# Expo/빌드 산출물
+.expo/
+.expo/*
+_now.status
+*.log
+# (ejected가 아니라면)
+ios/
+android/
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
M .expo/devices.json
M App.js
M package-lock.json
M package.json
M src/services/PushNotificationService.ios.js
?? .expo/prebuild/
?? .expo/web/
?? .expo/xcodebuild-error.log
?? .expo/xcodebuild.log
?? _now.status
?? ios/
# Expo build artifacts
.expo/
.expo/*
# Now.sh local status file
_now.status
# Log files
*.log
# If the project is not ejected
ios/
android/
🤖 Prompt for AI Agents
In _now.status around lines 1 to 11: this PR includes generated build/state
artifacts (e.g. _now.status, .expo/* logs, ios/) that shouldn't be committed;
remove those files from the PR (git rm --cached <files> or revert their
addition) and add appropriate ignore patterns to .gitignore (e.g. .expo/,
.expo/*.log, ios/, _now.status) then commit the updated .gitignore and the
removals; ensure you also check for any accidental sensitive data in those files
before finishing the commit.

Comment on lines 2 to 11
"devices": [
{
"installationId": "FE1F6210-9324-47BF-971B-25ED7730754F",
"lastUsed": 1757391686135
},
{
"installationId": "453B7860-472B-43DC-987E-3F54A4AA6955",
"lastUsed": 1757381271381
}
]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

개인 디바이스 식별자(.expo/devices.json) 커밋됨 — 보안/프라이버시 및 재현성 문제

로컬 개발 아티팩트로서 저장소에 두면 안 됩니다. installationId는 개인 식별 가능성이 있으며, 팀원 간 충돌도 유발합니다. 파일 제거하고 .gitignore에 .expo/를 제외하세요.

적용 제안(diff: 파일 삭제):

--- a/.expo/devices.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
-  "devices": [
-    {
-      "installationId": "FE1F6210-9324-47BF-971B-25ED7730754F",
-      "lastUsed": 1757391686135
-    },
-    {
-      "installationId": "453B7860-472B-43DC-987E-3F54A4AA6955",
-      "lastUsed": 1757381271381
-    }
-  ]
-}

추가로 .gitignore에 다음 항목을 넣는 것을 권장합니다:

+/.expo/
+.expo-shared/
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"devices": [
{
"installationId": "FE1F6210-9324-47BF-971B-25ED7730754F",
"lastUsed": 1757391686135
},
{
"installationId": "453B7860-472B-43DC-987E-3F54A4AA6955",
"lastUsed": 1757381271381
}
]
🤖 Prompt for AI Agents
In .expo/devices.json (lines 2-11): this file contains personal device
installationIds and must be removed from the repo; delete the file and commit
the deletion, add ".expo/" to .gitignore so the directory and its artifacts are
not tracked going forward, and ensure the removed file is no longer tracked by
Git (unstage/remove it from the index and commit). Optionally, if you need to
purge the identifier from history, run a history-rewrite tool off the main
branch after coordinating with the team.

App.js Outdated
Comment on lines 10 to 15
// Push 서비스 (Promise 체인 버전)
import {
setupNotificationListeners,
registerExpoPushToken,
} from "./src/services/PushNotificationService";

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Android에서 즉시 크래시 위험: iOS 전용 export를 Android 모듈에서 찾지 못함

  • ./src/services/PushNotificationService는 iOS에선 .ios.js로 해소되지만, Android에선 .js로 해소됩니다. Android 파일이 registerExpoPushToken를 export하지 않으면 앱 시작 시점에 ImportError로 크래시납니다(조건문에서 호출하지 않아도 import 자체가 실패).

iOS에서만 동적으로 로드하도록 수정하세요:

-// Push 서비스 (Promise 체인 버전)
-import {
-  setupNotificationListeners,
-  registerExpoPushToken,
-} from "./src/services/PushNotificationService";
+// iOS에서만 동적 로드(안드로이드 번들 시 불필요 export 참조 방지)
+let setupNotificationListeners, registerExpoPushToken;

그리고 iOS 분기 안에서 require:

       if (Platform.OS !== "ios") {
         console.log("[Push] (skipped) iOS 전용 로직. 현재:", Platform.OS);
         return;
       }
 
+      // iOS 전용 모듈 로드
+      ({ setupNotificationListeners, registerExpoPushToken } =
+        require("./src/services/PushNotificationService"));
+
       if (!Device.isDevice) {

대안: Android용 src/services/PushNotificationService.js에 동일 시그니처의 no-op export를 추가.

Also applies to: 81-84

🤖 Prompt for AI Agents
In App.js around lines 10-15 (and also the similar imports at 81-84), importing
registerExpoPushToken from ./src/services/PushNotificationService
unconditionally causes an ImportError/crash on Android when that module's
Android variant doesn't export it; to fix, remove the top-level import and
instead load the iOS-only implementation dynamically inside the iOS branch using
require(...) so the symbol is only resolved on iOS, or alternatively add a no-op
export with the same signature to src/services/PushNotificationService.js
(Android) so the import is safe on both platforms.

Comment on lines 30 to 31
`${API_BASE_URL}api/stock/autocomplete/?query=${query}`
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

쿼리 파라미터 인코딩 누락 — 특수문자 검색 시 실패 가능

사용자 입력은 encodeURIComponent로 인코딩하세요.

적용 제안(diff):

-        `${API_BASE_URL}api/stock/autocomplete/?query=${query}`
+        `${API_BASE_URL}api/stock/autocomplete/?query=${encodeURIComponent(query)}`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`${API_BASE_URL}api/stock/autocomplete/?query=${query}`
);
`${API_BASE_URL}api/stock/autocomplete/?query=${encodeURIComponent(query)}`
);
🤖 Prompt for AI Agents
In src/screens/Main/SearchScreen.js around lines 30 to 31, the query parameter
is inserted directly into the URL which can break on special characters; update
the request to encode the user input (e.g. replace the raw ${query} with an
encoded value using encodeURIComponent(query) or build the query with
URLSearchParams) so the query param is properly percent-encoded before sending.

Comment on lines 57 to 58
`${API_BASE_URL}api/stock/search/?query=${query}`
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

검색 API도 동일하게 인코딩 적용 필요

적용 제안(diff):

-        `${API_BASE_URL}api/stock/search/?query=${query}`
+        `${API_BASE_URL}api/stock/search/?query=${encodeURIComponent(query)}`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`${API_BASE_URL}api/stock/search/?query=${query}`
);
`${API_BASE_URL}api/stock/search/?query=${encodeURIComponent(query)}`
);
🤖 Prompt for AI Agents
In src/screens/Main/SearchScreen.js around lines 57-58, the search URL currently
interpolates the raw query into the query string; update it to URL-encode the
query parameter (use encodeURIComponent(query)) when building the request URL so
special characters/spaces are sent safely; replace the direct ${query} insertion
with an encoded version and verify no double-encoding occurs (handle
null/undefined query gracefully).

Comment on lines 52 to 56
// FCM 토큰을 Expo Push Token으로 변환
// Expo는 FCM 토큰을 자동으로 Expo Push Token 형식으로 변환합니다
const expoPushToken = `ExponentPushToken[${fcmToken}]`;

console.log("📢 [Push] FCM → Expo Push Token 변환 완료:", expoPushToken);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Expo Push Token을 FCM 문자열로 감싸는 방식은 동작하지 않습니다

ExponentPushToken[${fcmToken}] 형태로 직접 구성하면 유효한 Expo 토큰이 아닙니다. Expo 서버에서 발급받아야 합니다.

수정안(실제 발급 API 사용):

-    // FCM 토큰을 Expo Push Token으로 변환
-    // Expo는 FCM 토큰을 자동으로 Expo Push Token 형식으로 변환합니다
-    const expoPushToken = `ExponentPushToken[${fcmToken}]`;
+    // Expo Push Token 발급
+    const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// FCM 토큰을 Expo Push Token으로 변환
// Expo는 FCM 토큰을 자동으로 Expo Push Token 형식으로 변환합니다
const expoPushToken = `ExponentPushToken[${fcmToken}]`;
console.log("📢 [Push] FCM → Expo Push Token 변환 완료:", expoPushToken);
// Expo Push Token 발급
const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId });
console.log("📢 [Push] FCM → Expo Push Token 변환 완료:", expoPushToken);

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/screens/Auth/LoginScreen.js (2)

103-105: 민감정보(토큰) 로그 유출 위험: 전체 응답 본문 로깅 금지

responseText에는 access/refresh 토큰이 포함될 수 있습니다. 디바이스 로그로의 토큰 유출은 보안 사고로 직결됩니다. 해당 로그를 제거하세요.

-      const responseText = await response.text();
-      console.log("응답 본문:", responseText);
+      const responseText = await response.text();
+      // 민감정보 가능성 있는 본문은 로깅하지 않습니다.

132-140: registerPushToken 반환 타입 변경 대비: 성공 판정 로직 보강

iOS의 registerPushToken은 객체를 반환합니다. 현재 truthy 체크만으로는 실패/스킵도 “성공”으로 로그될 수 있습니다. 아래처럼 성공 여부를 명시적으로 판정하세요.

-        try {
-          const pushTokenSuccess = await registerPushToken(navigation);
-          if (pushTokenSuccess) {
-            console.log("✅ Push Token 등록 성공");
-          } else {
-            console.warn("Push Token 등록 실패");
-          }
-        } catch (pushError) {
+        try {
+          const pushRes = await registerPushToken(navigation);
+          const pushOk =
+            typeof pushRes === "boolean"
+              ? pushRes
+              : !!(pushRes?.success && (pushRes?.expoPushToken || pushRes?.skipped));
+          if (pushOk) {
+            console.log("✅ Push Token 등록 성공/스킵");
+          } else {
+            console.warn("⚠️ Push Token 등록 실패");
+          }
+        } catch (pushError) {
           console.error(" Push Token 등록 중 오류:", pushError);
         }
♻️ Duplicate comments (1)
.expo/devices.json (1)

2-15: 개인 디바이스 식별자(.expo/devices.json) 커밋됨 — 즉시 제거 및 추적 방지 필요

installationId는 개인 식별 가능 정보이며 로컬 개발 아티팩트입니다. 저장소에서 삭제하고 .expo 디렉터리를 .gitignore에 추가해 재발을 막으세요. 기록 정리가 필요하면 히스토리에서 파일을 제거하는 것도 고려하세요.

적용 제안:

--- a/.expo/devices.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
-  "devices": [
-    {
-      "installationId": "2A00458E-CB06-4F84-954E-01AB454A72C9",
-      "lastUsed": 1757484949022
-    },
-    {
-      "installationId": "FE1F6210-9324-47BF-971B-25ED7730754F",
-      "lastUsed": 1757480930842
-    },
-    {
-      "installationId": "453B7860-472B-43DC-987E-3F54A4AA6955",
-      "lastUsed": 1757381271381
-    }
-  ]
-}

또한 .gitignore에 추가:

+.expo/
+.expo-shared/
🧹 Nitpick comments (20)
src/screens/Main/StockDetail.js (2)

116-121: 심볼에 공백을 포함시키지 말고 결합 시점에만 추가하는 편이 안전

현재 " \u25B2 "처럼 심볼 문자열 내부에 공백이 포함되어 있어, 재활용 시 예기치 않은 여백 문제가 생길 수 있습니다. 심볼은 그대로 두고(▲/▼), 표기 조합 시에만 공백을 붙이면 재사용성이 좋아집니다.

다음처럼 수정 제안드립니다:

-        const changeSign =
-        changeData.change_status === "up"
-          ? " \u25B2 "  
-          : changeData.change_status === "down"
-          ? " \u25BC "  
-          : "";
+        const changeSymbol =
+          changeData.change_status === "up"
+            ? "\u25B2"
+            : changeData.change_status === "down"
+            ? "\u25BC"
+            : "";
+        const changePrefix = changeSymbol ? `${changeSymbol} ` : "";

-          change: `${changeSign}${Math.abs(
+          change: `${changePrefix}${Math.abs(
             changeData.price_change_percentage
           ).toFixed(2)}`,

Also applies to: 133-136


116-120: ▲/▼ 심볼 사용 통일
현재 레거시 ⏶/⏷는 사용되지 않으나, StockDetail.js에서는 "\u25B2"/"\u25BC"를, RecommendedStock.js·SignUp1Screen.js·TradingSellScreen.js·TradingBuyScreen.js·StockTradeScreen.js 등에서는 literal ▲/▼를 혼용하고 있습니다. 통일된 방식(예: 상수화)으로 리팩터링하세요.

app.json (3)

51-55: Expo Push Token 용 projectId 추가 OK — 런타임에서 null/미설정 대비 확인 필요

개발/Expo Go/Dev Client 환경에서 Constants.easConfig.projectId 또는 extra.eas.projectId가 비어 있을 수 있습니다. 토큰 요청 전에 projectId 유효성 검사를 넣고, 미존재 시 우아하게 우회(로그만 남기고 스킵)하세요.

원하시면 App.js/PushNotificationService 쪽에 안전 가드 로직 패치를 제안드릴게요.


42-50: Android 알림 아이콘은 단색(흰색) 투명 PNG 권장

현재 아이콘이 컬러일 가능성이 높습니다. Android 알림 아이콘은 흰색 단색/투명 배경(예: 24×24/48×48 dp)이어야 시스템이 틴팅합니다. 전용 아이콘을 두고 경로를 분리하세요.

예시 수정:

   [
     "expo-notifications",
     {
-      "icon": "./src/assets/icons/star-filled.png",
+      "icon": "./src/assets/notification-icons/ic_stat_notification.png",
       "color": "#F074BA"
     }
   ]

5-5: expo.owner 설정의 조직 접근성 점검

owner가 개인 계정이면 팀 빌드/프로젝트 권한에 영향이 있습니다. 조직 소유로 전환하거나 팀 권한을 명확히 설정했는지 확인하세요.

src/navigation/MainTab.js (4)

38-47: 키보드 표시 시 탭바 자동 숨김 권장

입력 화면에서 레이아웃 충돌을 줄이려면 tabBarHideOnKeyboard를 켜두는 것이 안전합니다.

       screenOptions={({ route }) => ({
         headerShown: false,
+        tabBarHideOnKeyboard: true,
         tabBarStyle: {

53-54: iOS 상단 헤어라인/그림자 완전 제거

shadowOpacity만 0으로는 남는 경우가 있어 shadowColor와 borderTopWidth도 명시하는 편이 확실합니다.

-          elevation: 0,
-          shadowOpacity: 0, // iOS 그림자 제거
+          elevation: 0,
+          shadowColor: 'transparent',
+          shadowOpacity: 0, // iOS 그림자 제거
+          borderTopWidth: 0,

56-57: 렌더 시 과도한 로그 — DEV 환경에서만 출력하도록 제한

tabBarIcon은 자주 렌더됩니다. 콘솔 스팸을 방지하세요.

-          console.log(`Tab pressed: ${route.name}, focused: ${focused}`);
+          if (__DEV__) {
+            // eslint-disable-next-line no-console
+            console.log(`Tab pressed: ${route.name}, focused: ${focused}`);
+          }

67-68: 아이콘 크기 50×50은 과대 — 플랫폼 가이드에 맞게 축소 권장(24–32dp)

탭바 높이 증가와 터치 타겟 왜곡을 유발할 수 있습니다.

-          return <Icon width={50} height={50} />; 
+          return <Icon width={28} height={28} />;
src/screens/Chatbot/ChatbotScreen.js (3)

104-107: setTimeout 기반 스크롤은 타이밍 이슈/누수 위험이 있습니다. onContentSizeChange로 치환 권장.

컨텐츠 변경 시 자동 스크롤이 더 안정적입니다.

-      setTimeout(() => {
-        scrollRef.current?.scrollToEnd({ animated: true });
-      }, 100);
+      // onContentSizeChange에서 일괄 처리
@@
-        setTimeout(() => {
-          scrollRef.current?.scrollToEnd({ animated: true });
-        }, 100);
+        // onContentSizeChange에서 일괄 처리
-        <ScrollView
+        <ScrollView
           ref={scrollRef}
           style={styles.chatScroll}
           contentContainerStyle={[
             styles.chatContainer,
             { paddingBottom: bottomOffset + INPUT_BAR_HEIGHT + 20 },
           ]}
+          onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
           keyboardShouldPersistTaps="handled"
           showsVerticalScrollIndicator={false}
         >

Also applies to: 133-136, 178-187


510-513: 죽은 스타일 제거 또는 애니메이션 구현 필요.

typingDot2의 주석은 동작하지 않습니다. 불필요하면 제거하거나 Animated 시퀀스로 실제 깜박임을 구현하세요.

-  typingDot2: {
-    // animationDelay는 RN 스타일 속성이 아님. 필요시 Animated로 처리 권장
-  },
+  // typingDot2 삭제 또는 Animated로 구현

29-31: 상단 안전영역 반영 누락.

iPhone 노치 환경에서 header 상단 패딩을 insets.top으로 보정하면 겹침을 줄일 수 있습니다.

-  header: {
-    paddingTop: 60,
+  header: {
+    paddingTop: 60, // 동적 패딩으로 대체 권장: 스타일 대신 inline 보정

예: header View 사용처에서 style={[styles.header, { paddingTop: Math.max(insets.top, 60) }]}로 적용.

Also applies to: 364-371

src/screens/Guide/GuideScreen.js (3)

48-56: 순차 fetch → 병렬화로 체감 로딩 개선 (3회 호출 동시 처리).

LEVELS가 고정 3개라 하더라도 병렬이 깔끔합니다.

-          const map = {};
-          for (const levelId of LEVELS) {
-            const res = await fetch(`${API_BASE_URL}progress/level/${levelId}/`, {
-              method: "GET",
-              headers: { Authorization: `Bearer ${accessToken}` },
-            });
-            if (!res.ok) throw new Error(`Level ${levelId} fetch failed: ${res.status}`);
-            map[levelId] = await res.json();
-          }
+          const resList = await Promise.all(
+            LEVELS.map((levelId) =>
+              fetch(`${API_BASE_URL}progress/level/${levelId}/`, {
+                method: "GET",
+                headers: { Authorization: `Bearer ${accessToken}` },
+              }).then((r) => ({ levelId, res: r }))
+            )
+          );
+          const map = {};
+          for (const { levelId, res } of resList) {
+            if (!res.ok) throw new Error(`Level ${levelId} fetch failed: ${res.status}`);
+            map[levelId] = await res.json();
+          }

96-106: 매직 넘버(56, 14, 8) 분리 권장.

FAB 크기/라벨 여백 등 의미 있는 값이면 상수로 선언해 가독성과 유지보수성을 올리세요.

-            paddingBottom: tabBarHeight + Math.max(insets.bottom, 0) + 56 + 14 + 8,
+            paddingBottom: tabBarHeight + Math.max(insets.bottom, 0) + FAB_SIZE + FAB_LABEL_HEIGHT + EXTRA_MARGIN,

상단에:

const FAB_SIZE = 56;
const FAB_LABEL_HEIGHT = 14;
const EXTRA_MARGIN = 8;

97-98: 상단 안전영역 반영으로 헤더/첫 섹션 겹침 방지.

container의 고정 paddingTop:50 대신 insets.top 기반 동적 패딩을 더해 주세요.

-    <View style={styles.container}>
+    <View style={[styles.container, { paddingTop: Math.max(insets.top, 50) }]}>

그리고 styles.container의 paddingTop은 제거/기본값으로 유지:

-  container: { flex: 1, backgroundColor: "#003340", paddingTop: 50 },
+  container: { flex: 1, backgroundColor: "#003340" },

Also applies to: 221-223

src/screens/Auth/SignUp1Screen.js (2)

250-257: 약관 표시 순서 보장 및 키 안전성.

Object.keys는 순서를 보장하지 않습니다. 명시 순서를 위해 배열로 키를 정의해 순회하세요.

-        {Object.keys(termsData).map(key => renderTermsItem(key))}
+        {['required1','required2','required6','optional2'].map((key) => renderTermsItem(key))}

244-247: 뒤로가기 접근성 개선(텍스트 기호 대신 아이콘/라벨).

'<' 텍스트는 스크린리더 친화적이지 않습니다. 접근성 라벨과 버튼 역할을 명시하세요.

-      <TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
-        <Text style={styles.backText}>{'<'}</Text>
-      </TouchableOpacity>
+      <TouchableOpacity
+        onPress={() => navigation.goBack()}
+        style={styles.backButton}
+        accessibilityRole="button"
+        accessibilityLabel="뒤로가기"
+      >
+        <Text style={styles.backText}>{'‹'}</Text>
+      </TouchableOpacity>
src/services/PushNotificationService.ios.js (1)

1-63: 죽은 코드(주석 블록) 제거 권장

상단의 대규모 주석 블록은 현재 구현과 중복/혼선을 유발합니다. 유지 보수성과 가독성을 위해 삭제하세요.

-// // src/services/PushNotificationService.ios.js
-// import { Platform } from "react-native";
-// import * as Device from "expo-device";
-// import * as Notifications from "expo-notifications";
-// import Constants from "expo-constants";
-// export function setupNotificationListeners() {
-//   const recvSub = Notifications.addNotificationReceivedListener((n) => {
-//     console.log("[Push][recv]:", n);
-//   });
-//   const respSub = Notifications.addNotificationResponseReceivedListener((r) => {
-//     console.log("[Push][tap]:", r);
-//   });
-//   return () => {
-//     Notifications.removeNotificationSubscription(recvSub);
-//     Notifications.removeNotificationSubscription(respSub);
-//   };
-// }
-// function getProjectId() {
-//   return (
-//     (Constants.easConfig && Constants.easConfig.projectId) ||
-//     (Constants.expoConfig?.extra?.eas?.projectId) ||
-//     null
-//   );
-// }
-// async function ensurePermissions() {
-//   const { status: existing } = await Notifications.getPermissionsAsync();
-//   if (existing === "granted") return "granted";
-//   const { status } = await Notifications.requestPermissionsAsync({
-//     ios: { allowAlert: true, allowBadge: true, allowSound: true },
-//   });
-//   return status;
-// }
-// export async function registerExpoPushToken() {
-//   try {
-//     if (Platform.OS !== "ios") throw new Error("iOS only");
-//     if (!Device.isDevice) throw new Error("실기기 필요");
-//     const perm = await ensurePermissions();
-//     if (perm !== "granted") throw new Error("알림 권한 거부됨");
-//     const projectId = getProjectId();
-//     if (!projectId) throw new Error("EAS projectId 없음");
-//     const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId });
-//     console.log("📢 [Push] ExpoPushToken:", expoPushToken);
-//     return { success: true, expoPushToken }; // DB에 그대로 저장 (ExponentPushToken[...] 형태)
-//   } catch (e) {
-//     const msg = e?.message || String(e);
-//     console.warn("[Push][ERR] registerExpoPushToken:", msg);
-//     return { success: false, error: msg };
-//   }
-// }
-//export const registerPushToken = registerExpoPushToken;
src/screens/MyPage/MyPageScreen.js (1)

19-20: 미사용 코드 정리 제안: import/상수 제거

increaseBalanceDEPOSIT_AMOUNT는 현재 사용되지 않습니다. 빌드 경고와 가독성 향상을 위해 제거하세요.

-import { increaseBalance } from "../../utils/point";
+// import { increaseBalance } from "../../utils/point"; // 사용 시에만 import
@@
-const DEPOSIT_AMOUNT = 100000;
+// const DEPOSIT_AMOUNT = 100000; // 사용 시에만 선언

Also applies to: 33-33

src/screens/Auth/LoginScreen.js (1)

197-205: 자동완성/키체인 친화 속성 추가 제안(UX 개선, 오타 감소)

입력란에 textContentType, autoComplete를 지정하면 iOS/Android 자동완성·키체인 연동이 더 매끄럽습니다.

               <TextInput
                 style={styles.input}
                 placeholder="이메일(아이디)를 입력해주세요"
                 placeholderTextColor="#CCCDD0"
                 value={email}
                 onChangeText={setEmail}
                 keyboardType="email-address"
                 autoCapitalize="none"
                 autoCorrect={false}
+                textContentType="emailAddress"
+                autoComplete="email"
               />
@@
                 <TextInput
                   style={styles.passwordInput}
                   placeholder="비밀번호를 입력해주세요"
                   placeholderTextColor="#CCCDD0"
                   secureTextEntry={seePassword}
                   value={password}
                   onChangeText={setPassword}
                   autoCorrect={false}
+                  textContentType="password"
+                  autoComplete="password"
                 />

Also applies to: 213-221

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9e62125 and c8ef2a8.

⛔ Files ignored due to path filters (7)
  • package-lock.json is excluded by !**/package-lock.json
  • src/assets/fonts/Pretendard-Bold.otf is excluded by !**/*.otf
  • src/assets/fonts/Pretendard-Medium.otf is excluded by !**/*.otf
  • src/assets/fonts/Pretendard-Regular.otf is excluded by !**/*.otf
  • src/assets/fonts/Pretendard-SemiBold.otf is excluded by !**/*.otf
  • src/assets/fonts/Pretendard-Thin.otf is excluded by !**/*.otf
  • src/assets/rainbow.png is excluded by !**/*.png
📒 Files selected for processing (12)
  • .expo/devices.json (1 hunks)
  • app.json (3 hunks)
  • package.json (2 hunks)
  • src/navigation/MainTab.js (3 hunks)
  • src/screens/Auth/LoginScreen.js (3 hunks)
  • src/screens/Auth/SignUp1Screen.js (6 hunks)
  • src/screens/Chatbot/ChatbotScreen.js (2 hunks)
  • src/screens/Guide/GuideScreen.js (5 hunks)
  • src/screens/Main/MainScreen.js (1 hunks)
  • src/screens/Main/StockDetail.js (1 hunks)
  • src/screens/MyPage/MyPageScreen.js (9 hunks)
  • src/services/PushNotificationService.ios.js (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/screens/Main/MainScreen.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • package.json
🧰 Additional context used
🧬 Code graph analysis (8)
src/screens/Main/StockDetail.js (4)
src/screens/Main/TradingSellScreen.js (2)
  • change (170-170)
  • change (169-169)
src/screens/Main/TradingBuyScreen.js (1)
  • change (174-178)
src/components/RecommendedStock.js (1)
  • stockData (152-163)
src/screens/Main/StockTradeScreen.js (3)
  • changeStatus (189-198)
  • changeStatus (178-187)
  • stock (231-313)
src/screens/Chatbot/ChatbotScreen.js (3)
src/navigation/MainTab.js (1)
  • insets (24-24)
src/screens/Guide/GuideScreen.js (2)
  • insets (30-30)
  • tabBarHeight (31-31)
src/utils/chatbotReply.js (2)
  • chatbotReply (4-25)
  • chatbotReply (4-25)
src/navigation/MainTab.js (2)
src/screens/Chatbot/ChatbotScreen.js (1)
  • insets (30-30)
src/screens/Guide/GuideScreen.js (1)
  • insets (30-30)
src/screens/Auth/LoginScreen.js (3)
src/utils/token.js (2)
  • email (68-68)
  • password (69-69)
src/components/EyeClosed.js (1)
  • EyeClosed (4-15)
src/components/EyeOpen.js (1)
  • EyeOpen (4-11)
src/screens/Auth/SignUp1Screen.js (2)
src/components/CheckBoxChecked.js (1)
  • CheckBoxChecked (30-39)
src/components/CheckBoxUnchecked.js (1)
  • CheckBoxChecked (20-37)
src/services/PushNotificationService.ios.js (1)
src/services/PushNotificationService.js (4)
  • navigation (45-99)
  • navigation (162-210)
  • navigation (213-246)
  • token (102-159)
src/screens/Guide/GuideScreen.js (6)
src/screens/Guide/GuideLevel1.js (2)
  • navigation (23-23)
  • navigation (22-126)
src/screens/Guide/GuideLevel2.js (2)
  • navigation (23-23)
  • navigation (22-132)
src/screens/Guide/GuideLevel3.js (2)
  • navigation (23-23)
  • navigation (22-133)
src/screens/Chatbot/ChatbotScreen.js (2)
  • insets (30-30)
  • tabBarHeight (31-31)
src/screens/Guide/TutorialScreen.js (1)
  • TutorialScreen (47-170)
src/components/LearningProgressBar.js (1)
  • LearningProgressBar (7-22)
src/screens/MyPage/MyPageScreen.js (5)
src/utils/mbtiType.js (6)
  • fetchUserMbtiType (5-50)
  • fetchUserMbtiType (5-50)
  • response (9-15)
  • data (27-27)
  • getMbtiImage (53-97)
  • getMbtiImage (53-97)
src/utils/token.js (11)
  • accessToken (7-7)
  • accessToken (129-129)
  • accessToken (171-171)
  • getNewAccessToken (126-153)
  • getNewAccessToken (126-153)
  • response (27-31)
  • response (82-86)
  • response (181-188)
  • email (68-68)
  • clearTokens (156-165)
  • clearTokens (156-165)
src/services/PushNotificationService.js (5)
  • accessToken (80-80)
  • response (93-104)
  • response (160-168)
  • unregisterPushToken (151-191)
  • unregisterPushToken (151-191)
src/utils/apiConfig.js (4)
  • response (69-75)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
  • data (81-81)
src/utils/user.js (5)
  • response (10-16)
  • response (53-60)
  • data (27-27)
  • fetchUserInfo (6-46)
  • fetchUserInfo (6-46)
🔇 Additional comments (5)
src/screens/Main/StockDetail.js (1)

116-120: ▲/▼ 심볼로 교체해 일관성 및 폰트 호환성 개선 — LGTM

다른 화면들(TradingSellScreen, TradingBuyScreen, RecommendedStock, StockTradeScreen)에서도 이미 ▲/▼를 사용하고 있어 앱 전반의 표현이 통일됩니다. 변경 자체는 적절합니다.

app.json (1)

22-22: iOS buildNumber 증가 확인

"12"로 증가 OK. 앱스토어에 배포된 빌드보다 항상 큰 값인지 확인하고, Android 쪽 versionCode도 EAS 설정 또는 app.json에서 함께 관리되는지 점검하세요.

src/services/PushNotificationService.ios.js (1)

147-164: 초기화 흐름 구성은 깔끔합니다

핸들러 설정 후 토큰 등록을 비동기 백그라운드로 넘겨 UI 블로킹을 방지한 점이 좋습니다.

src/screens/MyPage/MyPageScreen.js (1)

20-21: iOS 전용 서비스 파일 존재 및 export 누락 여부 확인 필요
src/services/PushNotificationService.ios.js 파일이 존재하는지 확인
– 존재한다면 해당 파일에 export const unregisterPushToken = async () => { … } 추가

src/screens/Auth/LoginScreen.js (1)

31-77: 키보드 등장/숨김 애니메이션 및 정리(cleanup) 처리 깔끔합니다

이벤트 리스너 등록/해제와 애니메이션 처리 흐름이 명확합니다. 좋습니다.

Comment on lines +27 to +35
const getTabBarHeight = () => {
const baseHeight = 60; // 기본 탭 높이
const paddingBottom = Platform.OS === 'android' ?
Math.max(insets.bottom, 15) : // 안드로이드: safe area 또는 최소 15
insets.bottom + 15; // iOS: safe area + 추가 여백

return baseHeight + paddingBottom;
};

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

탭바 높이 계산이 패딩과 중복되어 실제 높이가 과도하게 커질 수 있음

height에 base+padding을 포함하면서 paddingBottom도 별도로 더해 총 높이가 과대 계산됩니다. height 제거(또는 padding 중 하나만 사용)로 중복을 피하세요.

권장 수정안(중복 제거 + 메모이제이션):

 import { Platform } from 'react-native';
 import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@
   console.log('MainTab 나타났음');
-  const insets = useSafeAreaInsets();
-  
-  // 안드로이드와 iOS에서 다른 높이 계산
-  const getTabBarHeight = () => {
-    const baseHeight = 60; // 기본 탭 높이
-    const paddingBottom = Platform.OS === 'android' ? 
-      Math.max(insets.bottom, 15) : // 안드로이드: safe area 또는 최소 15
-      insets.bottom + 15; // iOS: safe area + 추가 여백
-    
-    return baseHeight + paddingBottom;
-  };
+  const insets = useSafeAreaInsets();
+  const isAndroid = Platform.OS === 'android';
+  const baseHeight = 60; // 기본 탭 높이
+  const bottomSpace = React.useMemo(
+    () => (isAndroid ? Math.max(insets.bottom, 15) : insets.bottom + 15),
+    [insets.bottom, isAndroid]
+  );
@@
         tabBarStyle: {
           backgroundColor: '#003340',
           borderTopColor: 'transparent',
-          height: getTabBarHeight(),
-          paddingBottom: Platform.OS === 'android' ? 
-            Math.max(insets.bottom, 15) : 
-            insets.bottom + 15,
+          // height는 고정하지 않고 최소 높이 + 안전영역만 패딩으로 적용
+          minHeight: baseHeight + 16,
+          paddingBottom: bottomSpace,
           paddingTop: 16,
           position: 'absolute',

Also applies to: 41-47


🏁 Script executed:

#!/bin/bash
# MainTab.js에서 getTabBarHeight 정의 및 사용 위치 확인
rg -n -C3 "useSafeAreaInsets" src/navigation/MainTab.js
rg -n -C3 "getTabBarHeight" src/navigation/MainTab.js
rg -n -C3 "tabBarStyle" src/navigation/MainTab.js

Length of output: 1395


paddingBottom 중복 제거 및 메모이제이션 적용
탭바 높이 계산에서 getTabBarHeight()baseHeight + paddingBottom을 반환하고, 스타일에 paddingBottom을 별도 추가해 실제 높이가 과도하게 계산됩니다. height 대신 minHeight+paddingBottom 구조로 변경하고, safe-area 하단 여백 계산을 useMemo로 감싸 중복을 제거하세요.

 import { Platform } from 'react-native';
 import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@
-  const insets = useSafeAreaInsets();
-  
-  // 안드로이드와 iOS에서 다른 높이 계산
-  const getTabBarHeight = () => {
-    const baseHeight = 60; // 기본 탭 높이
-    const paddingBottom = Platform.OS === 'android' ? 
-      Math.max(insets.bottom, 15) : // 안드로이드: safe area 또는 최소 15
-      insets.bottom + 15; // iOS: safe area + 추가 여백
-    
-    return baseHeight + paddingBottom;
-  };
+  const insets = useSafeAreaInsets();
+  const isAndroid = Platform.OS === 'android';
+  const baseHeight = 60; // 기본 탭 높이
+  const bottomSpace = React.useMemo(
+    () => (isAndroid ? Math.max(insets.bottom, 15) : insets.bottom + 15),
+    [insets.bottom, isAndroid]
+  );
@@
         tabBarStyle: {
           backgroundColor: '#003340',
           borderTopColor: 'transparent',
-          height: getTabBarHeight(),
-          paddingBottom: Platform.OS === 'android' ? 
-            Math.max(insets.bottom, 15) : 
-            insets.bottom + 15,
+          minHeight: baseHeight + 16,
+          paddingBottom: bottomSpace,
           paddingTop: 16,
           position: 'absolute',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getTabBarHeight = () => {
const baseHeight = 60; // 기본 탭 높이
const paddingBottom = Platform.OS === 'android' ?
Math.max(insets.bottom, 15) : // 안드로이드: safe area 또는 최소 15
insets.bottom + 15; // iOS: safe area + 추가 여백
return baseHeight + paddingBottom;
};
// ── src/navigation/MainTab.js ──
import React from 'react';
import { Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// … inside your MainTab component …
// 기존 getTabBarHeight 제거 후 safe-area 하단 여백만 메모이제이션
const insets = useSafeAreaInsets();
const isAndroid = Platform.OS === 'android';
const baseHeight = 60; // 기본 탭 높이
const bottomSpace = React.useMemo(
() =>
isAndroid
? Math.max(insets.bottom, 15) // 안드로이드: safe-area 또는 최소 15
: insets.bottom + 15, // iOS: safe-area + 추가 여백
[insets.bottom, isAndroid]
);
return (
<Tab.Navigator
screenOptions={{
tabBarStyle: {
backgroundColor: '#003340',
borderTopColor: 'transparent',
- height: getTabBarHeight(),
- paddingBottom: Platform.OS === 'android'
- ? Math.max(insets.bottom, 15)
// height 대신 minHeight + paddingBottom 구조 적용
minHeight: baseHeight + 16,
paddingBottom: bottomSpace,
paddingTop: 16,
position: 'absolute',
},
}}
>
{ /* …tabs… */ }
</Tab.Navigator>
);

await AsyncStorage.setItem("refreshToken", refresh);
await AsyncStorage.setItem("userEmail", email);
await AsyncStorage.setItem("userPassword", password); // ❗ 자동 로그인을 위해 password도 저장
await AsyncStorage.setItem("userPassword", password);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

사용자 비밀번호를 AsyncStorage(평문)로 저장 — 고위험 보안 취약점

비밀번호의 평문 저장은 금지되어야 합니다. refresh token 기반 재발급을 사용 중이므로 비밀번호 저장은 불필요합니다. 즉시 제거하고, 비밀번호 의존 로직이 없는지 점검하세요.

-        await AsyncStorage.setItem("userPassword", password);
+        // 비밀번호는 저장하지 않습니다. (보안)

추가로, 저장된 비밀번호를 참조하는 코드가 있는지 검증:

필요 시 utils/token.js에서도 비밀번호 의존을 제거하세요(예시):

-const email = await AsyncStorage.getItem("userEmail");
-const password = await AsyncStorage.getItem("userPassword");
-// ... fallback 로그인 로직
+// refresh token 재발급 실패 시 로그인 화면으로 유도
+// 비밀번호는 저장/재사용하지 않음

🏁 Script executed:

#!/bin/bash
# userPassword 사용 흔적 점검
rg -n -C2 '\buserPassword\b'

Length of output: 1953


사용자 비밀번호 저장 및 참조 완전 제거

  • src/screens/Auth/LoginScreen.js (라인 124): AsyncStorage.setItem("userPassword", password) 삭제
  • src/utils/token.js (라인 68–71): AsyncStorage.getItem("userPassword") 및 이를 이용한 fallback 로그인 로직 제거
  • src/screens/MyPage/MyPageScreen.js (라인 181, 206, 269): AsyncStorage.removeItem("userPassword") 호출 제거
🤖 Prompt for AI Agents
In src/screens/Auth/LoginScreen.js around line 124, remove the call that stores
the plaintext password (AsyncStorage.setItem("userPassword", password)) so the
app no longer persists user passwords; in src/utils/token.js around lines 68–71
remove the AsyncStorage.getItem("userPassword") usage and delete the fallback
login logic that uses that stored password (ensure any references/branches
depending on that fallback are cleaned up or replaced with proper error
handling); in src/screens/MyPage/MyPageScreen.js at lines ~181, 206, and 269
remove calls to AsyncStorage.removeItem("userPassword") and any code that
expects the key to exist (adjust flow/state accordingly). Ensure no other
modules reference the "userPassword" key and run tests/lint to confirm no
remaining references.

Comment on lines +9 to +13
required1: false, // 두둑 이용 약관
//required2: false, // 개인정보 수집·이용 동의
required6: false, // 만 14세 이상
optional2: false, // 광고성 정보 수신 동의
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

[치명적] required2(개인정보 수집·이용 동의)를 화면에서 주석 처리했지만 로직에서 필수로 요구합니다. 버튼이 항상 비활성화됩니다.

agreements/expandedStates/termsData에 required2가 없는데 allRequiredAgreed와 toggleAll/toggleAgreement는 required2를 참조합니다. 결과적으로 개별 동의로는 진행 불가(‘전체 동의’로만 가능하거나 아예 불가) 상태가 됩니다. 필수 항목을 복구하세요.

   const [agreements, setAgreements] = useState({
     all: false,
-    required1: false, // 두둑 이용 약관
-    //required2: false, // 개인정보 수집·이용 동의
+    required1: false, // 두둑 이용 약관
+    required2: false, // 개인정보 수집·이용 동의
     required6: false, // 만 14세 이상
     optional2: false, // 광고성 정보 수신 동의
   });
@@
   const [expandedStates, setExpandedStates] = useState({
     required1: false,
-    //required2: false,
+    required2: false,
     required6: false,
     optional2: false,
   });
@@
-//     required2: {
-//       title: '[필수] 개인정보 수집·이용 동의',
-//       content: `**수집하는 개인정보 항목**
-// ...
-// **동의를 거부할 권리**
-// 귀하는 개인정보 수집·이용에 대한 동의를 거부할 권리가 있습니다. 다만, 필수항목에 대한 동의를 거부할 경우 서비스 이용이 제한될 수 있습니다.`
-//     },
+    required2: {
+      title: '[필수] 개인정보 수집·이용 동의',
+      content: `**수집하는 개인정보 항목**
+- 필수항목: 이메일, 닉네임, 생년월일, 성별
+
+**개인정보 수집 및 이용 목적**
+1. 회원가입 의사 확인 및 회원제 서비스 제공
+2. 투자 시뮬레이션 데이터 관리
+3. 서비스 이용 안내 및 문의사항 응답
+4. 부정 이용 방지 및 서비스 개선
+
+**개인정보 보유 및 이용 기간**
+- 회원 탈퇴 시까지 보유하며, 탈퇴 후 즉시 삭제됩니다. (관련 법령 예외 제외)
+
+**동의를 거부할 권리**
+필수항목 동의 거부 시 서비스 이용이 제한될 수 있습니다.`
+    },
@@
-  const allRequiredAgreed = agreements.required1 && agreements.required2 && agreements.required6;
+  const allRequiredAgreed = agreements.required1 && agreements.required2 && agreements.required6;

Also applies to: 15-20, 92-109, 240-241

Comment on lines +160 to 169
const toggleAgreement = (key) => {
setAgreements((prevAgreements) => {
const newAgreements = { ...prevAgreements, [key]: !prevAgreements[key] };
newAgreements.all =
newAgreements.required1 &&
newAgreements.required2 &&
newAgreements.required3 &&
newAgreements.required4 &&
newAgreements.required5 &&
newAgreements.required6 &&
newAgreements.optional1 &&
newAgreements.optional2;
return newAgreements;
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

'전체 동의' 판단 로직을 실제 termsData와 동기화하세요.

하드코딩 대신 termsData의 required* 키 집합으로 all을 계산하면 항목 증감 시 오류를 줄일 수 있습니다.

-    setAgreements((prevAgreements) => {
-      const newAgreements = { ...prevAgreements, [key]: !prevAgreements[key] };
-      newAgreements.all =
-        newAgreements.required1 &&
-        newAgreements.required2 &&
-        newAgreements.required6 &&
-        newAgreements.optional2;
-      return newAgreements;
-    });
+    setAgreements((prev) => {
+      const next = { ...prev, [key]: !prev[key] };
+      const requiredKeys = Object.keys(termsData).filter((k) => k.startsWith('required'));
+      const optionalKeys = Object.keys(termsData).filter((k) => k.startsWith('optional'));
+      const requiredOk = requiredKeys.every((rk) => !!next[rk]);
+      const optionalOk = optionalKeys.every((ok) => !!next[ok]); // '전체 동의'는 선택 포함
+      next.all = requiredOk && optionalOk;
+      return next;
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const toggleAgreement = (key) => {
setAgreements((prevAgreements) => {
const newAgreements = { ...prevAgreements, [key]: !prevAgreements[key] };
newAgreements.all =
newAgreements.required1 &&
newAgreements.required2 &&
newAgreements.required3 &&
newAgreements.required4 &&
newAgreements.required5 &&
newAgreements.required6 &&
newAgreements.optional1 &&
newAgreements.optional2;
return newAgreements;
});
const toggleAgreement = (key) => {
setAgreements((prev) => {
const next = { ...prev, [key]: !prev[key] };
const requiredKeys = Object.keys(termsData).filter((k) => k.startsWith('required'));
const optionalKeys = Object.keys(termsData).filter((k) => k.startsWith('optional'));
const requiredOk = requiredKeys.every((rk) => !!next[rk]);
const optionalOk = optionalKeys.every((ok) => !!next[ok]); // '전체 동의'는 선택 포함
next.all = requiredOk && optionalOk;
return next;
});
🤖 Prompt for AI Agents
In src/screens/Auth/SignUp1Screen.js around lines 160 to 169, the
toggleAgreement function currently hardcodes the keys used to compute
newAgreements.all (required1, required2, required6, optional2); change this to
compute "all" dynamically from the current termsData required keys instead of
hardcoding: derive the list of required keys from termsData (e.g.,/filter for
keys with required flag or keys starting with 'required') and then set
newAgreements.all to true only if every required key in that derived list is
true in newAgreements; ensure this logic runs inside the setAgreements updater
so additions/removals of required terms in termsData automatically reflect in
the all calculation.

Comment on lines +88 to +102
const sendMessage = useCallback(
async (messageText = input) => {
if (!messageText.trim()) return;

const userMsg = {
sender: "user",
text: messageText,
timestamp: Date.now(),
};
const loadingMsg = { sender: "bot", text: "typing", timestamp: Date.now() };

setMessages((prev) => [...prev, userMsg, loadingMsg]);
setInput("");
setLoading(true);
setShowSuggestions(false);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

동시 전송 가드 추가 필요: 로딩 중 중복 전송 시 'typing' pop 로직이 정상 메시지를 제거할 수 있습니다.

로딩 중에도 onSubmitEditing/추천 질문 탭으로 sendMessage가 재실행될 수 있어 메시지 순서 꼬임/중복 발생 위험이 큽니다. 로딩 가드와 트리거 측의 방지 처리를 함께 넣어주세요.

-  const sendMessage = useCallback(
-    async (messageText = input) => {
+  const sendMessage = useCallback(
+    async (messageText = input) => {
+      if (loading) return; // 중복 전송 방지
       if (!messageText.trim()) return;
@@
-              onPress={() => sendMessage(item.text)}
+              onPress={() => !loading && sendMessage(item.text)}
@@
-                onSubmitEditing={() => sendMessage()}
+                onSubmitEditing={() => { if (!loading) sendMessage(); }}
@@
-    [input]
+    [input, loading]
   );

Also applies to: 268-272, 316-316

🤖 Prompt for AI Agents
In src/screens/Chatbot/ChatbotScreen.js around lines 88-102 (and also apply the
same change at 268-272 and 316), add a re-entry guard so sendMessage immediately
returns if loading is true; move/set loading state before appending the "typing"
placeholder or ensure you only append userMsg and loadingMsg when not already
loading to avoid removing real messages, and update the UI triggers
(onSubmitEditing and suggestion tap) to check loading before calling sendMessage
(disable the tap/submit action or early-return) so duplicate sends cannot be
invoked while a request is in flight.

Comment on lines +160 to +176
const prevComplete = levelId === 1 || progressMap[levelId - 1]?.is_level_completed;
const showLockIcon = !prevComplete;

const label = `${levelId}단계`;
const onPress = () => navigation.navigate(`GuideLevel${levelId}`);

return (
<View key={levelId} style={styles.levelBlock}>
{data.is_level_completed ? (
<ClearButton label={label} onPress={onPress} />
) : (
<UnClearButton onPress={onPress}>
<View style={styles.labelWithIcon}>
<Text style={styles.menuText}>{label}</Text>
{showLockIcon && <LockIcon style={styles.lockIcon} width={20} height={20} />}
</View>
</UnClearButton>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

레벨 잠금 표시만 있고 내비게이션은 열립니다. 잠금 시 이동 차단/가이드가 필요합니다.

현재 prevComplete=false여도 onPress가 항상 해당 레벨 화면으로 이동합니다. UX/권한 일관성을 위해 잠금 시 Alert만 표시하도록 조건 분기하세요.

-            const onPress = () => navigation.navigate(`GuideLevel${levelId}`);
+            const onPress = () => {
+              if (levelId !== 1 && !prevComplete) {
+                Alert.alert("잠금", "이전 단계를 완료하면 열립니다.");
+                return;
+              }
+              navigation.navigate(`GuideLevel${levelId}`);
+            };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const prevComplete = levelId === 1 || progressMap[levelId - 1]?.is_level_completed;
const showLockIcon = !prevComplete;
const label = `${levelId}단계`;
const onPress = () => navigation.navigate(`GuideLevel${levelId}`);
return (
<View key={levelId} style={styles.levelBlock}>
{data.is_level_completed ? (
<ClearButton label={label} onPress={onPress} />
) : (
<UnClearButton onPress={onPress}>
<View style={styles.labelWithIcon}>
<Text style={styles.menuText}>{label}</Text>
{showLockIcon && <LockIcon style={styles.lockIcon} width={20} height={20} />}
</View>
</UnClearButton>
const prevComplete = levelId === 1 || progressMap[levelId - 1]?.is_level_completed;
const showLockIcon = !prevComplete;
const label = `${levelId}단계`;
const onPress = () => {
if (levelId !== 1 && !prevComplete) {
Alert.alert("잠금", "이전 단계를 완료하면 열립니다.");
return;
}
navigation.navigate(`GuideLevel${levelId}`);
};
return (
<View key={levelId} style={styles.levelBlock}>
{data.is_level_completed ? (
<ClearButton label={label} onPress={onPress} />
) : (
<UnClearButton onPress={onPress}>
<View style={styles.labelWithIcon}>
<Text style={styles.menuText}>{label}</Text>
{showLockIcon && <LockIcon style={styles.lockIcon} width={20} height={20} />}
</View>
</UnClearButton>
🤖 Prompt for AI Agents
In src/screens/Guide/GuideScreen.js around lines 160 to 176, the level tiles
always navigate despite being locked; change the onPress behavior so that if
prevComplete is false (locked) it does NOT call navigation.navigate but instead
shows an Alert notifying the user the level is locked. Update the onPress to
check prevComplete (or showLockIcon) and call Alert.alert with a short
title/body when locked; ensure Alert is imported from 'react-native' and pass
the original navigation.navigate only when prevComplete is true so unlocked
levels still navigate.

Comment on lines 133 to 135
const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId });
console.log("📢 [Push] ExpoPushToken:", expoPushToken);
return { success: true, expoPushToken };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Expo Push Token 전체 로그 출력은 민감정보 노출 위험

토큰은 인증용 자격 증명으로 간주되어야 합니다. 전체 값을 로그로 남기지 마세요. 최소한 부분 마스킹하세요.

-    console.log("📢 [Push] ExpoPushToken:", expoPushToken);
+    const masked = typeof expoPushToken === "string"
+      ? `${expoPushToken.slice(0, 12)}…${expoPushToken.slice(-6)}`
+      : "<unknown>";
+    console.log("📢 [Push] ExpoPushToken(obfuscated):", masked);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId });
console.log("📢 [Push] ExpoPushToken:", expoPushToken);
return { success: true, expoPushToken };
const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId });
const masked = typeof expoPushToken === "string"
? `${expoPushToken.slice(0, 12)}${expoPushToken.slice(-6)}`
: "<unknown>";
console.log("📢 [Push] ExpoPushToken(obfuscated):", masked);
return { success: true, expoPushToken };
🤖 Prompt for AI Agents
In src/services/PushNotificationService.ios.js around lines 133 to 135, the code
currently logs the full Expo push token which is sensitive; change the logging
to never print the full token by replacing the console.log with a masked version
(e.g., show only the first 6 and last 4 characters or hash the token) and
implement a small maskToken(token) helper to produce that masked string; also
guard the log so it only runs in non-production environments (or remove it
entirely) while still returning the full expoPushToken in the function response
as before.

Comment on lines 143 to 144
// 기존 호출부 호환
export const registerPushToken = registerExpoPushToken;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

iOS에서 unregisterPushToken 미노출 + 반환타입 불일치로 호출부 오동작

  • MyPageScreen/LoginScreen 등에서 unregisterPushToken/registerPushToken을 사용합니다. iOS 전용 파일은 unregisterPushToken을 export하지 않아 iOS 번들에서 import 실패/런타임 오류가 납니다.
  • 또한 iOS의 registerExpoPushToken()은 객체를 반환(예: { success, skipped, expoPushToken })하지만 호출부는 boolean으로 처리하고 있어 항상 “성공”으로 로그될 수 있습니다.

호환 래퍼를 제공해 boolean을 반환하고, 토큰을 로컬에 저장하며, 해제 함수도 노출하세요.

아래 패치 후 iOS에서 빌드/실행 시 import 에러가 사라지는지, 로그인 시 푸시 등록 성공/실패 로그가 정확히 반영되는지 확인 바랍니다.

-// 기존 호출부 호환
-export const registerPushToken = registerExpoPushToken;
+// 기존 호출부 호환: boolean 반환 + 로컬 저장
+export async function registerPushToken() {
+  const res = await registerExpoPushToken();
+  if (res?.success && res?.expoPushToken) {
+    try {
+      await AsyncStorage.setItem("pushToken", res.expoPushToken);
+    } catch (e) {
+      console.warn("[Push][WARN] pushToken persist failed:", e?.message || String(e));
+    }
+    return true;
+  }
+  // 등록을 스킵한 경우도 앱 흐름상 실패로 치지 않음
+  if (res?.skipped) return true;
+  return false;
+}
+
+// iOS에서도 해제 API를 노출(서버 연동이 없다면 로컬 정리만)
+export async function unregisterPushToken() {
+  try {
+    await AsyncStorage.removeItem("pushToken");
+    return true;
+  } catch (e) {
+    console.warn("[Push][ERR] unregisterPushToken:", e?.message || String(e));
+    // 해제 실패가 앱 플로우를 막지는 않도록 true 반환 유지(기존 구현과 정합)
+    return true;
+  }
+}

🏁 Script executed:

#!/usr/bin/env bash
set -eux
# locate the iOS push notification service file
FILE=$(fd PushNotificationService.ios.js -t f | head -n1)
echo "iOS file: $FILE"
# show lines with registerPushToken, unregisterPushToken, registerExpoPushToken
grep -nE "registerPushToken|unregisterPushToken|registerExpoPushToken" "$FILE"
# search throughout src for definition of registerExpoPushToken
rg -nR "function registerExpoPushToken|const registerExpoPushToken" -n src
# search for any export of unregisterPushToken
rg -nR "export .*unregisterPushToken" -n src

Length of output: 1245


🏁 Script executed:

#!/usr/bin/env bash
set -eux
# iOS 서비스 파일에서 registerExpoPushToken 함수 정의와 alias 코드 일부 출력
sed -n '100,160p' src/services/PushNotificationService.ios.js

# unregisterPushToken가 export 되었는지 확인
grep -R "export .*unregisterPushToken" -n src/services/PushNotificationService.ios.js || true

# 프로젝트 전반에서 registerPushToken/unregisterPushToken 사용 위치 검색
grep -R "registerPushToken" -n src || true
grep -R "unregisterPushToken" -n src || true

Length of output: 3641


iOS PushNotificationService에 unregisterPushToken 노출 및 registerPushToken boolean 래퍼 추가

  • src/services/PushNotificationService.ios.js에서 unregisterPushToken이 export되지 않아 MyPageScreen import 오류 발생
  • registerPushToken이 registerExpoPushToken alias로 객체를 반환하지만 호출부는 boolean으로 처리 → 실패 케이스도 항상 성공으로 간주

아래처럼 수정하세요:

- // 기존 호출부 호환
- export const registerPushToken = registerExpoPushToken;
+ // 기존 호출부 호환: boolean 반환 + 로컬 저장
+ export async function registerPushToken() {
+   const res = await registerExpoPushToken();
+   if (res?.success && res?.expoPushToken) {
+     try {
+       await AsyncStorage.setItem("pushToken", res.expoPushToken);
+     } catch (e) {
+       console.warn("[Push][WARN] pushToken persist failed:", e?.message || String(e));
+     }
+     return true;
+   }
+   if (res?.skipped) return true;
+   return false;
+ }
+
+ // iOS에서도 해제 API를 노출
+ export async function unregisterPushToken() {
+   try {
+     await AsyncStorage.removeItem("pushToken");
+   } catch (e) {
+     console.warn("[Push][ERR] unregisterPushToken:", e?.message || String(e));
+   }
+   return true;
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 기존 호출부 호환
export const registerPushToken = registerExpoPushToken;
// 기존 호출부 호환: boolean 반환 + 로컬 저장
export async function registerPushToken() {
const res = await registerExpoPushToken();
if (res?.success && res?.expoPushToken) {
try {
await AsyncStorage.setItem("pushToken", res.expoPushToken);
} catch (e) {
console.warn("[Push][WARN] pushToken persist failed:", e?.message || String(e));
}
return true;
}
if (res?.skipped) return true;
return false;
}
// iOS에서도 해제 API를 노출
export async function unregisterPushToken() {
try {
await AsyncStorage.removeItem("pushToken");
} catch (e) {
console.warn("[Push][ERR] unregisterPushToken:", e?.message || String(e));
}
return true;
}
🤖 Prompt for AI Agents
In src/services/PushNotificationService.ios.js around lines 143 to 144, the
module currently only aliases registerExpoPushToken to registerPushToken and
does not export unregisterPushToken, causing import errors and returning an
object where callers expect a boolean; update the file to (1) export
unregisterPushToken so MyPageScreen can import it, and (2) replace the direct
alias with an async boolean wrapper named registerPushToken that calls
registerExpoPushToken, returns true when the underlying call succeeds, and
returns false (or catches errors and returns false) when it fails so callers
receive a proper boolean result.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/screens/Chatbot/ChatbotScreen.js (1)

85-99: 중복 전송 · 메시지 손실 가능성: in-flight 가드 추가 및 트리거 차단 필요

로딩 중 재진입 시 마지막 메시지 pop()이 실제 메시지를 제거할 수 있습니다. 함수 내부 가드만으로는 setState 비동기 타이밍을 막기 어렵기 때문에 ref 기반 동시성 가드를 권장합니다. 또한 추천 질문 탭/키보드 전송에서도 로딩 시 호출을 차단하세요.

아래 패치를 적용해주세요:

@@
-  const scrollRef = useRef(null);
+  const scrollRef = useRef(null);
+  const inFlightRef = useRef(false);
   const fadeAnim = useRef(new Animated.Value(0)).current;
   const slideAnim = useRef(new Animated.Value(50)).current;
@@
   const sendMessage = useCallback(
     async (messageText = input) => {
-      if (!messageText.trim()) return;
+      if (!messageText.trim()) return;
+      if (loading || inFlightRef.current) return; // 재진입 가드
+      inFlightRef.current = true;
+      setLoading(true); // 최대한 앞당겨 설정
+      setShowSuggestions(false);
 
       const userMsg = {
         sender: "user",
         text: messageText,
         timestamp: Date.now(),
       };
       const loadingMsg = { sender: "bot", text: "typing", timestamp: Date.now() };
 
-      setMessages((prev) => [...prev, userMsg, loadingMsg]);
-      setInput("");
-      setLoading(true);
-      setShowSuggestions(false);
+      setMessages((prev) => [...prev, userMsg, loadingMsg]);
+      setInput("");
@@
       } finally {
         setLoading(false);
+        inFlightRef.current = false;
         setTimeout(() => {
           scrollRef.current?.scrollToEnd({ animated: true });
         }, 100);
       }
     },
-    [input]
+    [input, loading]
   );
@@
-                <TouchableOpacity
+                <TouchableOpacity
                   key={`${item.text}-${idx}`}
-                  onPress={() => sendMessage(item.text)}
+                  onPress={() => !loading && sendMessage(item.text)}
+                  disabled={loading}
                   style={styles.suggestionCard}
                   activeOpacity={0.7}
                 >
@@
-                onSubmitEditing={() => sendMessage()}
+                onSubmitEditing={() => { if (!loading) sendMessage(); }}

Also applies to: 276-289, 316-337, 134-136, 40-43

🧹 Nitpick comments (30)
src/screens/Chatbot/ChatbotScreen.js (4)

152-164: TypingIndicator 애니메이션 무한 루프 정리

재귀 start(() => animate())는 언마운트 시 정리 없이 계속 돌 수 있습니다. Animated.loop로 전환하고 cleanup에서 stop() 하세요.

-    React.useEffect(() => {
-      const animate = () => {
-        Animated.sequence([
-          Animated.timing(dot1Anim, { toValue: 1, duration: 400, useNativeDriver: true }),
-          Animated.timing(dot2Anim, { toValue: 1, duration: 400, useNativeDriver: true }),
-          Animated.timing(dot3Anim, { toValue: 1, duration: 400, useNativeDriver: true }),
-          Animated.timing(dot1Anim, { toValue: 0.4, duration: 400, useNativeDriver: true }),
-          Animated.timing(dot2Anim, { toValue: 0.4, duration: 400, useNativeDriver: true }),
-          Animated.timing(dot3Anim, { toValue: 0.4, duration: 400, useNativeDriver: true }),
-        ]).start(() => animate());
-      };
-      animate();
-    }, []);
+    React.useEffect(() => {
+      const loop = Animated.loop(
+        Animated.sequence([
+          Animated.timing(dot1Anim, { toValue: 1, duration: 400, useNativeDriver: true }),
+          Animated.timing(dot2Anim, { toValue: 1, duration: 400, useNativeDriver: true }),
+          Animated.timing(dot3Anim, { toValue: 1, duration: 400, useNativeDriver: true }),
+          Animated.timing(dot1Anim, { toValue: 0.4, duration: 400, useNativeDriver: true }),
+          Animated.timing(dot2Anim, { toValue: 0.4, duration: 400, useNativeDriver: true }),
+          Animated.timing(dot3Anim, { toValue: 0.4, duration: 400, useNativeDriver: true }),
+        ])
+      );
+      loop.start();
+      return () => loop.stop();
+    }, []);

191-198: 입력바 높이 하드코딩으로 인해 겹침 가능성: 동적 측정으로 치환

INPUT_BAR_HEIGHT는 멀티라인 확장 시 실제 높이와 불일치할 수 있어 스크롤 영역/추천 패널이 겹칠 수 있습니다. onLayout으로 실제 높이를 반영하세요.

@@
-const INPUT_BAR_HEIGHT = 70;
+const INPUT_BAR_HEIGHT = 70; // 기본값(초기 padding 계산용)
@@
-  const [input, setInput] = useState("");
+  const [input, setInput] = useState("");
   const [loading, setLoading] = useState(false);
   const [showSuggestions, setShowSuggestions] = useState(false);
+  const [inputBarHeight, setInputBarHeight] = useState(INPUT_BAR_HEIGHT);
@@
-          contentContainerStyle={[
-            styles.chatContainer,
-            { paddingBottom: bottomOffset + INPUT_BAR_HEIGHT + 20 },
-          ]}
+          contentContainerStyle={[
+            styles.chatContainer,
+            { paddingBottom: bottomOffset + inputBarHeight + 20 },
+          ]}
@@
-              styles.suggestionContainer,
-              {
-                bottom: bottomOffset + INPUT_BAR_HEIGHT + 12,
+              styles.suggestionContainer,
+              {
+                bottom: bottomOffset + inputBarHeight + 12,
                 opacity: fadeAnim,
                 transform: [{ translateY: slideAnim }],
               },
@@
-        <View
+        <View
           style={[
             styles.inputBar,
             {
               paddingBottom: Math.max(insets.bottom, 12),
               bottom: bottomOffset,
             },
           ]}
+          onLayout={(e) => setInputBarHeight(e.nativeEvent.layout.height)}
         >

Also applies to: 259-263, 292-301, 21-25, 37-41


191-252: 긴 대화에서 성능 개선: ScrollView → FlatList 전환 제안

메시지 수가 많아지면 ScrollView는 메모리/리렌더 비용이 큽니다. FlatListinverted 모드와 onContentSizeChange/scrollToEnd 최소화로 전환하면 성능과 자동 스크롤 안정성이 좋아집니다.

원하시면 FlatList 변환 PR용 패치도 준비하겠습니다.


333-344: 접근성 라벨 추가 제안

전송/추천 버튼에 accessibilityRole="button"과 의미 있는 accessibilityLabel을 부여하면 스크린리더 사용성이 향상됩니다.

-            <TouchableOpacity
+            <TouchableOpacity
+              accessibilityRole="button"
+              accessibilityLabel={loading ? "전송 중" : "메시지 전송"}
               onPress={() => sendMessage()}
@@
-                <TouchableOpacity
+                <TouchableOpacity
+                  accessibilityRole="button"
+                  accessibilityLabel={`추천 질문: ${item.text}`}
                   key={`${item.text}-${idx}`}

Also applies to: 277-282, 303-314

src/screens/Guide/GuideLevel3.js (6)

45-55: API URL 결합 시 // 중복 가능성 — URL 정상화 제안

API_BASE_URL가 슬래시(/)로 끝나므로 현재 문자열 결합은 //progress... 형태가 됩니다. 일부 환경에서 301/캐시 미스로 이어질 수 있어 URL을 안전하게 결합하세요.

다음처럼 수정을 권장합니다:

-      const res = await fetch(
-        `${API_BASE_URL}progress/level/3/content/`,
-        {
+      const url = new URL('progress/level/3/content/', API_BASE_URL);
+      const res = await fetch(
+        url.toString(),
+        {
           method: 'GET',
           headers: { 
             'Authorization': `Bearer ${accessToken}`,
             'Content-Type': 'application/json',
           },
         }
       );

33-69: 네트워크 타임아웃/취소 신설로 UX 안정화

무한 대기 방지를 위해 AbortController 기반 타임아웃(예: 10초)을 추가하세요.

   const fetchProgress = async () => {
     setLoading(true);
     setError(null);
     
     try {
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), 10000);
       const accessToken = await getNewAccessToken(navigation);
       if (!accessToken) {
         Alert.alert('인증 오류', '토큰이 만료되었습니다. 다시 로그인해주세요.');
         navigation.navigate('Login');
         return;
       }
 
-      const res = await fetch(
-        `${API_BASE_URL}progress/level/3/content/`,
-        {
+      const url = new URL('progress/level/3/content/', API_BASE_URL);
+      const res = await fetch(
+        url.toString(),
+        {
           method: 'GET',
           headers: { 
             'Authorization': `Bearer ${accessToken}`,
             'Content-Type': 'application/json',
           },
-        }
+          signal: controller.signal,
+        }
       );
       
       if (!res.ok) {
         throw new Error(`Level 3 content fetch failed: ${res.status}`);
       }
       
       const data = await res.json();
       setContentProgress(data.content_progress || {});
     } catch (err) {
       console.error('Error fetching progress:', err);
       setError(err.message);
       Alert.alert('데이터 오류', '진행도 정보를 불러오는 중 오류가 발생했습니다.');
     } finally {
+      clearTimeout(timeoutId);
       setLoading(false);
     }
   };

115-122: 뒤로가기 버튼 Safe Area 반영

절대 위치 top이 고정(50)이라 기기별 노치/상태표시줄에 따라 겹칠 수 있습니다. insets.top을 더해 주세요.

-      <TouchableOpacity
+      <TouchableOpacity
         onPress={() => navigation.goBack()}
-        style={styles.backButton}
+        style={[styles.backButton, { top: insets.top + 10 }]}
         accessibilityLabel="뒤로가기"
       >

154-160: 접근성 향상: 챕터 버튼에 역할/레이블 추가

터치 타깃에 role/label을 부여해 스크린리더 지원을 개선하세요.

-                  <TouchableOpacity 
-                    onPress={() => handleChapterPress(id)}
-                    activeOpacity={0.7}
-                    style={styles.iconTouchable}
-                  >
+                  <TouchableOpacity
+                    accessibilityRole="button"
+                    accessibilityLabel={`챕터 3-${id} 열기`}
+                    onPress={() => handleChapterPress(id)}
+                    activeOpacity={0.7}
+                    style={styles.iconTouchable}
+                  >

63-66: Alert + 화면 내 에러 UI 중복 노출 가능성

catch에서 Alert를 띄운 뒤 에러 화면도 렌더링되어 사용자 경험이 중복될 수 있습니다. 하나만 유지하는 것을 권장합니다.


33-75: 중복 로직 공통화 제안: useLevelContentProgress 훅으로 추출

Level1/2/3 모두 동일한 진행도 요청/에러 처리/재시도를 반복합니다. 공통 훅으로 추출해 유지보수를 줄이세요.

원한다면 useLevelContentProgress(level, navigation) 샘플 구현을 제공할게요.

src/screens/Guide/GuideLevel1.js (5)

43-49: API URL 결합 안전화

기저 URL이 슬래시로 끝나 //progress...가 될 수 있습니다. URL로 결합하세요.

-      const res = await fetch(`${API_BASE_URL}progress/level/1/content/`, {
+      const url = new URL('progress/level/1/content/', API_BASE_URL);
+      const res = await fetch(url.toString(), {
         method: 'GET',
         headers: {
           Authorization: `Bearer ${accessToken}`,
           'Content-Type': 'application/json',
         },
       });

31-62: 요청 타임아웃 추가 권장

무응답시 무한 로딩을 방지하도록 AbortController 타임아웃(예: 10초)을 추가하세요.

   const fetchProgress = async () => {
     setLoading(true);
     setError(null);
 
     try {
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), 10000);
       const accessToken = await getNewAccessToken(navigation);
       if (!accessToken) {
         Alert.alert('인증 오류', '토큰이 만료되었습니다. 다시 로그인해주세요.');
         navigation.navigate('Login');
         return;
       }
 
-      const res = await fetch(`${API_BASE_URL}progress/level/1/content/`, {
+      const url = new URL('progress/level/1/content/', API_BASE_URL);
+      const res = await fetch(url.toString(), {
         method: 'GET',
         headers: {
           Authorization: `Bearer ${accessToken}`,
           'Content-Type': 'application/json',
         },
-      });
+        signal: controller.signal,
+      });
 
       if (!res.ok) throw new Error(`Level 1 content fetch failed: ${res.status}`);
 
       const data = await res.json();
       setContentProgress(data?.content_progress || {});
     } catch (err) {
       console.error('Error fetching progress:', err);
       setError(err.message);
       Alert.alert('데이터 오류', '진행도 정보를 불러오는 중 오류가 발생했습니다.');
     } finally {
+      clearTimeout(timeoutId);
       setLoading(false);
     }
   };

101-109: 뒤로가기 버튼 Safe Area 반영

iPhone 노치 대응을 위해 top에 insets를 반영하세요.

-      <TouchableOpacity
+      <TouchableOpacity
         onPress={() => navigation.goBack()}
-        style={styles.backButton}
+        style={[styles.backButton, { top: insets.top + 10 }]}
         accessibilityLabel="뒤로가기"
       >

131-133: 접근성 레이블 추가

챕터 버튼에 role/label을 부여해 화면읽기 품질을 개선하세요.

-                  <TouchableOpacity onPress={() => handlePress(id)} activeOpacity={0.7} style={styles.iconTouchable}>
+                  <TouchableOpacity
+                    accessibilityRole="button"
+                    accessibilityLabel={`챕터 1-${id} 열기`}
+                    onPress={() => handlePress(id)}
+                    activeOpacity={0.7}
+                    style={styles.iconTouchable}
+                  >

31-75: 진행도 로딩 로직 공통화

Level1/2/3의 fetch/에러/재시도 패턴을 공용 훅/유틸로 추출하면 중복과 회귀 위험을 줄일 수 있습니다.

원하시면 리팩터링 패치 제안해 드립니다.

src/screens/Guide/StudyScreen.js (8)

59-68: 가이드 조회 URL 결합 안전화

API_BASE_URL의 후행 슬래시로 인해 //api/advanced-guides가 될 수 있습니다. URL을 사용하세요.

-      const res = await fetch(
-        `${API_BASE_URL}api/advanced-guides/${guideApiId}/`,
-        {
+      const url = new URL(`api/advanced-guides/${guideApiId}/`, API_BASE_URL);
+      const res = await fetch(
+        url.toString(),
+        {
           method: 'GET',
           headers: { 
             'Authorization': `Bearer ${accessToken}`,
             'Content-Type': 'application/json',
           },
         }
       );

133-141: 완료 처리 URL 결합 안전화

동일 이슈로 완료 API도 안전 결합을 권장합니다.

-      const res = await fetch(
-        `${API_BASE_URL}progress/complete/${level}/${contentIndex}/`,
-        {
+      const completeUrl = new URL(`progress/complete/${level}/${contentIndex}/`, API_BASE_URL);
+      const res = await fetch(
+        completeUrl.toString(),
+        {
           method: "POST",
           headers: { 
             'Authorization': `Bearer ${accessToken}`,
             'Content-Type': 'application/json',
           },
         }
       );

47-83: 가이드 fetch 타임아웃/취소 추가

무한 대기 방지 및 에러 복구성 강화를 위해 타임아웃을 권장합니다.

   const fetchGuide = async () => {
     setLoading(true);
     setError(null);
 
     try {
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), 10000);
       const accessToken = await getNewAccessToken(navigation);
       if (!accessToken) {
         Alert.alert("인증 오류", "토큰이 만료되었습니다. 다시 로그인해주세요.");
         navigation.navigate("Login");
         return;
       }
 
-      const res = await fetch(
-        `${API_BASE_URL}api/advanced-guides/${guideApiId}/`,
-        {
+      const url = new URL(`api/advanced-guides/${guideApiId}/`, API_BASE_URL);
+      const res = await fetch(
+        url.toString(),
+        {
           method: 'GET',
           headers: { 
             'Authorization': `Bearer ${accessToken}`,
             'Content-Type': 'application/json',
           },
-        }
+          signal: controller.signal,
+        }
       );
       
       if (!res.ok) {
         throw new Error(`Failed to load guide (${res.status})`);
       }
       
       const data = await res.json();
       setContent(data.content || "콘텐츠를 불러올 수 없습니다.");
     } catch (err) {
       console.error("Error fetching guide:", err);
       setError(err.message);
       setContent("콘텐츠를 불러오는 중 오류가 발생했습니다.");
     } finally {
+      clearTimeout(timeoutId);
       setLoading(false);
     }
   };

245-247: 원격 Markdown 렌더링 보안/링크 처리

링크 클릭 처리 시 javascript: 등 비정상 스킴을 차단하세요.

-          <Markdown style={markdownStyles}>{content}</Markdown>
+          <Markdown
+            style={markdownStyles}
+            onLinkPress={(url) => {
+              if (/^https?:\/\//i.test(url)) {
+                // Linking.openURL(url) 사용 시 추가 검증/화이트리스트 고려
+                require('react-native').Linking.openURL(url);
+              }
+              return false;
+            }}
+          >
+            {content}
+          </Markdown>

171-178: 로딩/에러 화면도 Safe Area 적용

상단 패딩이 없어 노치 하단에 겹칠 수 있습니다.

-      <View style={[styles.container, styles.center]}>
+      <View style={[styles.container, styles.center, { paddingTop: insets.top }]}>
         <ActivityIndicator size="large" color="#00AACC" />
         <Text style={styles.loadingText}>학습 콘텐츠를 불러오는 중...</Text>
       </View>
-      <View style={[styles.container, styles.center]}>
+      <View style={[styles.container, styles.center, { paddingTop: insets.top }]}>
         <Icon name="wifi-off" size={64} color="#666" style={{ opacity: 0.6 }} />
         <Text style={styles.errorText}>콘텐츠를 불러올 수 없습니다</Text>
         <TouchableOpacity style={styles.retryButton} onPress={handleRetry}>
           <Text style={styles.retryButtonText}>다시 시도</Text>
         </TouchableOpacity>
       </View>

Also applies to: 180-189


28-34: 가이드 ID 오프셋(8, 13) 하드코딩 검증 필요

레벨별 콘텐츠 수가 바뀌면 즉시 잘못된 ID를 조회합니다. 상수 테이블/서버 제공 매핑으로 치환을 권장하며, 현재 오프셋 값의 정확성 확인이 필요합니다.

원하면 const LEVEL_BASE = {1:0, 2:8, 3:13}; const guideApiId = LEVEL_BASE[level] + contentIndex; 형태로 분리하고, 서버에서 총 개수를 받아 동기화하는 방식을 제안드릴게요.


221-234: 진행 바 해상도(10단계) → 연속 값으로 부드럽게

10단계로 끊기지 않고 스크롤 비율에 따라 연속적으로 채우면 더 자연스럽습니다.

간단히 progressIndex 대신 progressPct (0~1)를 상태로 두고, width를 ${progressPct * 100}%로 계산하도록 리팩터링을 고려해 주세요.

Also applies to: 396-405


331-339: 헤더 영역 그림자/경계선 플랫폼 차이 고려

Android에서 borderBottomWidth만 사용 시 입체감이 약할 수 있습니다. elevation(Android)/shadow*(iOS) 보완을 권장합니다.

src/screens/Guide/GuideLevel2.js (7)

2-2: useCallback/useMemo를 import해 의존성/재연산 최소화 준비

향후 콜백/메모화를 위해 React.useCallback 대신 직접 import를 권장합니다.

-import React, { useState } from 'react';
+import React, { useState, useCallback, useMemo } from 'react';

Also applies to: 12-13


31-62: 포커스 진입 시 fetch 의존성 안전성 확보 및 401 처리 보강

  • fetchProgress를 useCallback으로 감싸 의존성 안정화.
  • API 401 응답 시 즉시 로그인 화면으로 유도(토큰 만료 재발 방지).
-  const fetchProgress = async () => {
+  const fetchProgress = useCallback(async () => {
     setLoading(true);
     setError(null);
@@
-      if (!res.ok) throw new Error(`Level 2 content fetch failed: ${res.status}`);
+      if (res.status === 401) {
+        Alert.alert('인증 오류', '다시 로그인해주세요.');
+        navigation.navigate('Login');
+        return;
+      }
+      if (!res.ok) throw new Error(`Level 2 content fetch failed: ${res.status}`);
@@
-  };
+  }, [navigation]);
@@
-  useFocusEffect(
-    React.useCallback(() => {
-      fetchProgress();
-    }, [])
-  );
+  useFocusEffect(
+    useCallback(() => {
+      fetchProgress();
+    }, [fetchProgress])
+  );

Also applies to: 64-68


55-59: 에러 UI와 Alert 이중 노출 최소화

catch에서 Alert까지 띄우면 별도 에러 화면과 중복 인지 가능성이 있습니다. 에러 화면만 사용하도록 단순화 권장합니다.

-      console.error('Error fetching progress:', err);
-      setError(err.message);
-      Alert.alert('데이터 오류', '진행도 정보를 불러오는 중 오류가 발생했습니다.');
+      console.error('Error fetching progress:', err);
+      setError(err.message);

87-96: 에러 화면에 재시도 버튼 testID 추가 (E2E 용이성)

Detox/WDIO 등 E2E에서 선택자 안정성을 높입니다.

-        <TouchableOpacity style={styles.retryButton} onPress={handleRetry}>
+        <TouchableOpacity
+          style={styles.retryButton}
+          onPress={handleRetry}
+          testID="level2_retry_button"
+          accessibilityRole="button"
+          accessibilityLabel="다시 시도"
+        >

98-104: entries/firstIncomplete 메모화로 불필요한 계산 방지

렌더마다 정렬/탐색을 피하기 위해 메모화를 권장합니다.

-  // progress 객체를 [ { id, done }, ... ] 형태로 변환 & 정렬
-  const entries = Object.entries(contentProgress)
-    .map(([key, done]) => ({ id: Number(key), done }))
-    .sort((a, b) => a.id - b.id);
-
-  const firstIncomplete = entries.find((e) => !e.done)?.id;
+  // progress 객체를 [ { id, done }, ... ] 형태로 변환 & 정렬
+  const entries = useMemo(() => (
+    Object.entries(contentProgress)
+      .map(([key, done]) => ({ id: Number(key), done }))
+      .sort((a, b) => a.id - b.id)
+  ), [contentProgress]);
+
+  const firstIncomplete = useMemo(
+    () => entries.find((e) => !e.done)?.id,
+    [entries]
+  );

107-113: 뒤로가기 버튼도 안전영역 고려

노치 디바이스에서 겹침 방지를 위해 top을 insets 기반으로 보정하세요.

-      <TouchableOpacity
-        onPress={() => navigation.goBack()}
-        style={styles.backButton}
+      <TouchableOpacity
+        onPress={() => navigation.goBack()}
+        style={[styles.backButton, { top: insets.top + 10 }]}
         accessibilityLabel="뒤로가기"
       >

134-141: 챕터 아이콘 터치 영역 접근성 개선

스크린리더/테스트 안정성을 위해 접근성 속성 및 testID를 추가하세요.

-                  <TouchableOpacity
-                    onPress={() => handleChapterPress(id)}
-                    activeOpacity={0.7}
-                    style={styles.iconTouchable}
-                  >
+                  <TouchableOpacity
+                    onPress={() => handleChapterPress(id)}
+                    activeOpacity={0.7}
+                    style={styles.iconTouchable}
+                    accessibilityRole="button"
+                    accessibilityLabel={`챕터 2-${id} 열기`}
+                    testID={`level2_chapter_${id}_btn`}
+                  >
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c8ef2a8 and dd1e0b4.

📒 Files selected for processing (7)
  • .expo/devices.json (1 hunks)
  • app.json (3 hunks)
  • src/screens/Chatbot/ChatbotScreen.js (2 hunks)
  • src/screens/Guide/GuideLevel1.js (8 hunks)
  • src/screens/Guide/GuideLevel2.js (8 hunks)
  • src/screens/Guide/GuideLevel3.js (8 hunks)
  • src/screens/Guide/StudyScreen.js (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • app.json
  • .expo/devices.json
🧰 Additional context used
🧬 Code graph analysis (5)
src/screens/Guide/StudyScreen.js (5)
src/screens/Guide/GuideLevel3.js (7)
  • screenWidth (24-24)
  • navigation (27-27)
  • insets (28-28)
  • error (31-31)
  • handleRetry (77-79)
  • loading (29-29)
  • styles (176-277)
src/screens/Guide/GuideLevel1.js (6)
  • navigation (24-24)
  • insets (25-25)
  • error (29-29)
  • handleRetry (70-70)
  • loading (27-27)
  • styles (147-233)
src/screens/Guide/GuideLevel2.js (6)
  • navigation (24-24)
  • insets (25-25)
  • error (29-29)
  • handleRetry (70-70)
  • loading (27-27)
  • styles (155-241)
src/utils/token.js (5)
  • accessToken (7-7)
  • accessToken (129-129)
  • accessToken (171-171)
  • getNewAccessToken (126-153)
  • getNewAccessToken (126-153)
src/utils/apiConfig.js (2)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
src/screens/Guide/GuideLevel3.js (3)
src/screens/Guide/StudyScreen.js (6)
  • screenWidth (20-20)
  • navigation (24-24)
  • insets (25-25)
  • error (38-38)
  • handleRetry (167-169)
  • styles (290-478)
src/screens/Guide/GuideLevel1.js (7)
  • navigation (24-24)
  • insets (25-25)
  • error (29-29)
  • fetchProgress (31-62)
  • handleRetry (70-70)
  • styles (147-233)
  • firstIncomplete (99-99)
src/screens/Guide/GuideLevel2.js (8)
  • navigation (24-24)
  • insets (25-25)
  • error (29-29)
  • fetchProgress (31-62)
  • handleRetry (70-70)
  • handleChapterPress (72-77)
  • styles (155-241)
  • firstIncomplete (103-103)
src/screens/Guide/GuideLevel2.js (3)
src/screens/Guide/GuideLevel1.js (5)
  • loading (27-27)
  • error (29-29)
  • fetchProgress (31-62)
  • navigation (24-24)
  • styles (147-233)
src/screens/Guide/GuideLevel3.js (5)
  • loading (29-29)
  • error (31-31)
  • fetchProgress (33-69)
  • navigation (27-27)
  • styles (176-277)
src/screens/Guide/GuideScreen.js (4)
  • loading (34-34)
  • navigation (29-29)
  • styles (221-336)
  • navigation (25-184)
src/screens/Guide/GuideLevel1.js (1)
src/screens/Guide/GuideLevel2.js (7)
  • contentProgress (28-28)
  • error (29-29)
  • fetchProgress (31-62)
  • navigation (24-24)
  • styles (155-241)
  • entries (99-101)
  • firstIncomplete (103-103)
src/screens/Chatbot/ChatbotScreen.js (2)
src/screens/Guide/GuideScreen.js (3)
  • insets (30-30)
  • tabBarHeight (31-31)
  • loading (34-34)
src/utils/chatbotReply.js (2)
  • chatbotReply (4-25)
  • chatbotReply (4-25)
🔇 Additional comments (5)
src/screens/Chatbot/ChatbotScreen.js (3)

177-181: Android 키보드 겹침 대응 완료 — 굿

KeyboardAvoidingView에서 Android에 "height" 적용 확인했습니다. 이전 피드백 반영 잘 됐습니다.


316-331: 멀티라인 TextInput의 onSubmitEditing 동작 확인 필요(특히 Android)

Android 멀티라인에서는 onSubmitEditing이 트리거되지 않거나 개행이 입력되는 경우가 잦습니다. 현 상태에선 전송 버튼이 유일한 확실한 트리거일 수 있으니 실제 단말에서 동작을 검증해주세요. 필요 시 onKeyPress로 Enter 처리(Shift+Enter는 개행) 로직을 추가하는 것을 권장합니다.

Would you like me to draft a cross-platform 키 입력 처리 스니펫(Enter=전송, Shift+Enter=개행)?


596-600: gap 사용 — 현재 환경에서 호환됨 (react-native 0.79.5 / expo ~53.0.0)

package.json에서 react-native 0.79.5와 expo ~53.0.0으로 확인되며, gap은 React Native 0.71에서 도입되어 문서상 지원됩니다; Expo SDK 53은 RN 0.79을 포함합니다. (reactnative.dev)

따라서 src/screens/Chatbot/ChatbotScreen.js (줄 596–600)의 gap 사용은 유지해도 됩니다. 단, 구버전(0.70 이하)이나 일부 레이아웃 조합(flexWrap+alignContent)에서 과거 버그가 보고되어 레거시 호환성 필요 시 margin* 대체를 고려하세요. (github.com)

src/screens/Guide/GuideLevel2.js (2)

23-30: 레벨2 화면 전반 리팩터링과 안전영역/에러 상태 도입, 전반적으로 좋아요

토큰 기반 로딩, 에러 UI, 안전영역 패딩 적용이 일관되고 깔끔합니다. Level1/Level3와의 패턴 정렬도 좋습니다.


156-171: 상단 여백 중복 가능성 점검(안전영역 패딩 + title marginTop + scrollView paddingTop)

insets 기반 paddingTop과 title의 marginTop(60), scrollView의 paddingTop(20)이 합쳐져 상단 여백이 과할 수 있습니다. 실제 기기에서 시각 확인 후 조정 권장합니다.

Also applies to: 176-187

Comment on lines 22 to 28
const StudyScreen = () => {
const { level, contentIndex } = useRoute().params;
const navigation = useNavigation();
const insets = useSafeAreaInsets();

// map level & contentIndex → advanced-guide id
const guideApiId =
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

경로 파라미터 방어 로직 필요 (푸시/딥링크 대비)

useRoute().params가 없으면 즉시 크래시합니다. 안전 분해와 가드가 필요합니다.

-const StudyScreen = () => {
-  const { level, contentIndex } = useRoute().params;
+const StudyScreen = () => {
+  const route = useRoute();
+  const { level, contentIndex } = route.params ?? {};
+  if (level == null || contentIndex == null) {
+    return (
+      <View style={[styles.container, styles.center]}>
+        <Text style={styles.errorText}>잘못된 접근입니다. 이전 화면으로 돌아갑니다.</Text>
+      </View>
+    );
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const StudyScreen = () => {
const { level, contentIndex } = useRoute().params;
const navigation = useNavigation();
const insets = useSafeAreaInsets();
// map level & contentIndex → advanced-guide id
const guideApiId =
const StudyScreen = () => {
const route = useRoute();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { level, contentIndex } = route.params ?? {};
if (level == null || contentIndex == null) {
return (
<View style={[styles.container, styles.center]}>
<Text style={styles.errorText}>잘못된 접근입니다. 이전 화면으로 돌아갑니다.</Text>
</View>
);
}
// map level & contentIndex → advanced-guide id
const guideApiId =
🤖 Prompt for AI Agents
In src/screens/Guide/StudyScreen.js around lines 22 to 28, the current direct
destructuring from useRoute().params will crash when params are missing (e.g.
push/deep link); change to safely access params (useRoute()?.params or const
params = useRoute()?.params || {}) and provide default values or validation for
level and contentIndex, and if they are invalid redirect/back out
(navigation.goBack() or show an error screen) to avoid runtime crashes; ensure
subsequent logic uses the validated/defaulted values.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (6)
src/screens/Guide/StudyScreen.js (1)

24-24: 라우트 파라미터 검증 필요

useRoute().params에서 직접 구조 분해할 때 파라미터가 없으면 앱이 크래시됩니다. 푸시 알림이나 딥링크를 통해 접근할 때 파라미터가 누락될 수 있습니다.

#!/bin/bash
# StudyScreen이 다른 곳에서 어떻게 호출되는지 확인
rg -n "StudyScreen" --type js -C3
.expo/devices.json (1)

2-19: 개발 아티팩트 파일이 저장소에 커밋되어 보안 및 협업 문제 발생

.expo/devices.json은 개인 디바이스 식별자를 포함하는 로컬 개발 아티팩트입니다. 저장소에 포함되면 다음 문제가 발생합니다:

  • 개인 식별 가능한 installationId 노출
  • 팀원 간 디바이스 정보 충돌
  • 재현성 문제
App.js (3)

10-14: Android에서 크래시 위험: iOS 전용 모듈을 조건부로 로드해야 함

PushNotificationService를 최상위에서 import하면 Android에서 해당 export를 찾지 못해 앱 시작 시 크래시가 발생할 수 있습니다.


113-135: 생일까지 D-day 계산 시 시각 비교 오류

생일 당일 오후에도 "내년 생일"로 계산되어 D-365가 표시될 수 있습니다. 날짜를 0시로 정규화하여 비교해야 합니다.


123-143: 환영 알림이 프로덕션에서 노출될 위험

앱 시작 시마다 환영 알림이 발송되어 실제 사용자에게 테스트 메시지가 보일 수 있습니다.

src/screens/MyPage/MyPageScreen.js (1)

113-135: 생일 D-day 계산의 시각 비교 문제

현재 로직은 "올해 생일 00:00"과 "현재 시각"을 비교하여 당일 오후에도 "지남"으로 판단, D-365가 표시될 수 있습니다.

🧹 Nitpick comments (5)
src/screens/Auth/SignUp2Screen.js (3)

528-534: rAF만으로는 레이아웃 확정 전에 스크롤될 수 있습니다. InteractionManager 사용 고려

일부 기기/프레임에서 requestAnimationFrame 한 번으로는 레이아웃이 아직 확정되지 않아 끝까지 스크롤이 덜 되는 경우가 있습니다. 아래처럼 InteractionManager.runAfterInteractions로 보강을 권장합니다.

import { InteractionManager } from "react-native";

// ...
onFocus={() => {
  if (!showMoreAddr) setShowMoreAddr(true);
  InteractionManager.runAfterInteractions(() => {
    scrollRef.current?.scrollToEnd({ animated: true });
  });
}}

Also applies to: 545-551


82-87: bottomSpacer 계산에 Safe Area 반영을 권장합니다.

노치/홈 인디케이터가 있는 기기에서 하단 여백이 모자라거나 과해질 수 있습니다. react-native-safe-area-contextuseSafeAreaInsets를 사용해 보정하면 안정적입니다.

- const bottomSpacer = useMemo(() => {
-   if (!keyboardVisible) return 120; // 평소에도 넉넉히
-   return Math.max(220, keyboardHeight + 140);
- }, [keyboardVisible, keyboardHeight]);
+ const insets = useSafeAreaInsets();
+ const FOOTER_H = 76; // 버튼(52) + 푸터 패딩(대략치)
+ const bottomSpacer = useMemo(() => {
+   if (!keyboardVisible) return FOOTER_H + insets.bottom + 24;
+   return Math.max(FOOTER_H + insets.bottom + 40, keyboardHeight + 24);
+ }, [keyboardVisible, keyboardHeight, insets.bottom]);

추가: 상단 import에 아래를 포함해야 합니다.

import { useSafeAreaInsets } from "react-native-safe-area-context";

304-305: keyboardVerticalOffset의 매직 넘버(56)를 안전영역 기반으로 대체

헤더 높이/노치에 따라 값이 달라져야 합니다. Safe Area를 활용하면 기기별 보정이 일관됩니다.

-        keyboardVerticalOffset={Platform.OS === "ios" ? 56 : 0}
+        keyboardVerticalOffset={Platform.OS === "ios" ? insets.top + 16 : 0}

위 변경은 useSafeAreaInsets 도입(별도 코멘트 참고)을 전제로 합니다.

src/screens/Guide/StudyScreen.js (2)

149-159: 공유 기능에 실제 콘텐츠 포함 고려

현재 공유 메시지가 챕터 번호만 포함하고 있습니다. 학습 콘텐츠의 제목이나 간단한 설명을 포함하면 더 유용할 것입니다.

 const handleShare = async () => {
   try {
+    const contentPreview = content.substring(0, 100).replace(/[#*`\n]/g, '');
     await Share.share({
-      message: `챕터 ${level}-${contentIndex} 학습 콘텐츠를 공유합니다!`,
+      message: `챕터 ${level}-${contentIndex} 학습 콘텐츠\n\n${contentPreview}...`,
       title: '학습 콘텐츠 공유',
     });
   } catch (error) {
     console.error('공유 오류:', error);
   }
   setShowMenu(false);
 };

542-542: 주석 대신 명시적 섹션 구분 사용 권장

한 줄 주석으로 스타일 섹션을 구분하는 것보다 별도의 스타일 객체나 명확한 그룹핑을 사용하는 것이 좋습니다.

-  // 메뉴 모달 스타일
+  // Modal styles
   modalOverlay: {

또는 스타일을 별도 객체로 분리:

const modalStyles = StyleSheet.create({
  overlay: { ... },
  menuContainer: { ... },
  // ...
});
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dd1e0b4 and 0e0cdb1.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (7)
  • .expo/devices.json (1 hunks)
  • App.js (1 hunks)
  • app.json (3 hunks)
  • package.json (3 hunks)
  • src/screens/Auth/SignUp2Screen.js (5 hunks)
  • src/screens/Guide/StudyScreen.js (2 hunks)
  • src/screens/MyPage/MyPageScreen.js (10 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app.json
🧰 Additional context used
🧬 Code graph analysis (4)
src/screens/Auth/SignUp2Screen.js (3)
src/screens/Chatbot/ChatbotScreen.js (1)
  • scrollRef (40-40)
src/screens/Auth/LoginScreen.js (1)
  • keyboardVisible (26-26)
src/screens/MyPage/EditUserInfoScreen.js (1)
  • userInfo (22-155)
src/screens/MyPage/MyPageScreen.js (5)
src/utils/mbtiType.js (4)
  • fetchUserMbtiType (5-50)
  • fetchUserMbtiType (5-50)
  • response (9-15)
  • data (27-27)
src/utils/token.js (11)
  • accessToken (7-7)
  • accessToken (129-129)
  • accessToken (171-171)
  • getNewAccessToken (126-153)
  • getNewAccessToken (126-153)
  • response (27-31)
  • response (82-86)
  • response (181-188)
  • email (68-68)
  • clearTokens (156-165)
  • clearTokens (156-165)
src/services/PushNotificationService.js (5)
  • accessToken (80-80)
  • response (93-104)
  • response (160-168)
  • unregisterPushToken (151-191)
  • unregisterPushToken (151-191)
src/utils/apiConfig.js (4)
  • response (69-75)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
  • data (81-81)
src/utils/user.js (5)
  • response (10-16)
  • response (53-60)
  • data (27-27)
  • fetchUserInfo (6-46)
  • fetchUserInfo (6-46)
src/screens/Guide/StudyScreen.js (2)
src/utils/token.js (5)
  • accessToken (7-7)
  • accessToken (129-129)
  • accessToken (171-171)
  • getNewAccessToken (126-153)
  • getNewAccessToken (126-153)
src/utils/apiConfig.js (2)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
App.js (2)
src/services/PushNotificationService.ios.js (6)
  • Notifications (107-109)
  • setupNotificationListeners (71-82)
  • registerExpoPushToken (113-141)
  • setupNotificationListeners (140-170)
  • initial (159-164)
  • r (148-156)
src/services/PushNotificationService.js (6)
  • Notifications (33-33)
  • setupNotificationListeners (193-220)
  • setupNotificationListeners (193-220)
  • navigation (213-246)
  • Platform (27-42)
  • navigation (45-99)
🔇 Additional comments (28)
src/screens/Auth/SignUp2Screen.js (4)

16-17: Keyboard import 추가는 적절합니다.

아래 키보드 이벤트 처리(useEffect)와 일관됩니다.


63-66: 키보드 상태 추적(State) 추가는 간단명료합니다.

후속 계산(bottomSpacer) 및 스크롤 보정에 유용합니다.


559-560: 하단 Spacer View로 버튼 영역 보호 — OK

footer가 absolute인 구조에서 간단하고 확실한 방법입니다. 위에서 제안한 ref 연결과 함께 실제 단말에서 입력 포커스 전환 시 가림 현상 없는지만 한 번 더 확인 부탁드립니다.


67-81: iOS: keyboardWillShow/WillHide 사용 권장 — Android: windowSoftInputMode(adjustResize) 확인 필요

  • iOS: 애니메이션 타이밍상 keyboardWillShow / keyboardWillHide가 더 부드럽습니다. Platform별로 will/did 이벤트를 분기해 교체하세요.
  • Android: keyboardDidShow가 호출되지 않을 수 있으므로 android/app/src/main/AndroidManifest.xml의 windowSoftInputMode="adjustResize" 설정 또는 MainActivity에서 setSoftInputMode 호출 여부를 확인하세요.
  • 검사 결과: 제공된 스크립트로는 AndroidManifest.xml·MainActivity를 찾지 못했습니다. Android 네이티브 폴더가 표준 위치에 없거나 Expo managed workflow일 수 있으니 네이티브 경로를 알려주거나 네이티브 코드가 포함된 브랜치에서 재검사하세요.
src/screens/Guide/StudyScreen.js (4)

47-83: 토큰 갱신 로직이 적절하게 구현됨

getNewAccessToken을 사용하여 토큰을 안전하게 관리하고, 만료 시 로그인 화면으로 이동하는 처리가 잘 되어 있습니다. 에러 처리와 로딩 상태 관리도 적절합니다.


90-95: 스크롤 진행률 계산 개선됨

useCallback을 사용하여 불필요한 재생성을 방지하고, 진행률을 0-10 구간으로 나누어 백분율로 표시하는 로직이 깔끔합니다.


264-264: Markdown 스타일 동적 적용 잘 구현됨

사용자가 선택한 fontSize를 Markdown 컴포넌트에 동적으로 적용하는 방식이 적절합니다.


290-352: 메뉴 모달 UI/UX 개선 잘됨

다양한 기능(공유, 스크롤, 글꼴 크기 조절, 신고)을 제공하는 메뉴가 잘 구성되어 있습니다. 모달 오버레이 터치로 닫기 기능도 적절합니다.

package.json (4)

7-8: 스크립트 명령어가 Expo 개발 클라이언트 워크플로우로 적절히 업데이트됨

expo run:androidexpo run:ios 명령어로 변경하여 개발 클라이언트 빌드를 지원하도록 개선되었습니다. 이는 App.js의 푸시 알림 초기화 플로우와 잘 연동됩니다.


18-18: expo-constants 버전 제약 완화가 적절함

^17.1.7에서 *로 변경하여 더 유연한 버전 관리를 제공합니다. App.js에서 Constants.easConfig 등을 사용하는 푸시 알림 설정과 호환성이 좋습니다.


22-22: expo-notifications 버전 제약이 더 안정적으로 변경됨

캐럿(^)에서 틸드(~) 제약으로 변경하여 마이너 버전 호환성을 보장합니다. 이는 App.js의 새로운 푸시 알림 플로우에 적합합니다.


39-39: expo-dev-client 추가로 개발 환경 개선

새로운 의존성 추가로 Expo 개발 클라이언트를 지원합니다. 이는 푸시 알림 테스트와 디버깅에 도움이 됩니다.

App.js (5)

23-34: 전역 알림 핸들러 설정이 적절함

포그라운드에서 알림 배너, 리스트, 사운드, 배지를 모두 표시하도록 설정되어 사용자 경험이 향상됩니다.


45-57: 플랫폼 및 디바이스 검증 로직이 견고함

iOS 전용으로 제한하고 시뮬레이터에서는 건너뛰도록 하여 푸시 알림이 실제 작동 가능한 환경에서만 실행됩니다.


148-148: 초기화 지연 시간이 적절함

500ms 지연으로 앱 로딩이 완료된 후 푸시 알림을 초기화하여 안정성을 높입니다.


152-169: 리스너 정리 로직이 안전함

try-catch로 감싸서 정리 과정에서 에러가 발생해도 앱이 중단되지 않도록 보장합니다.


16-18: 프로덕션에 환영 알림이 항상 활성화됨 — 의도 확인 필요

SHOW_WELCOME_ON_LAUNCH = true로 설정되어 App.js에서 모든 사용자에게 scheduleNotificationAsync로 환영 알림을 예약합니다.
위치: App.js (16–18, 122–136)
조치: 개발 전용이면 SHOW_WELCOME_ON_LAUNCH = DEV 또는 환경변수/피처 플래그로 제한. 프로덕션 의도면 주석으로 명확히 표기.

src/screens/MyPage/MyPageScreen.js (11)

25-25: SafeAreaInsets 활용으로 레이아웃 개선

디바이스별 안전 영역을 고려한 레이아웃 구성으로 사용자 경험이 향상되었습니다.


33-34: MBTI 별명 상태 관리 추가

사용자 개인화를 위한 MBTI 별명 기능이 적절히 구현되었습니다.


43-90: MBTI 추천 API 호출 로직이 견고함

에러 처리와 로딩 상태 관리가 잘 구현되어 있으며, alias가 없는 경우에 대한 경고도 적절합니다.


137-144: MenuButton 컴포넌트에 iconColor prop 추가

아이콘 색상을 커스터마이징할 수 있도록 개선되어 UI 일관성이 향상됩니다.


148-158: 푸시 토큰 해제 로직에 navigation 파라미터 추가

unregisterPushToken(navigation)로 호출하여 토큰 해제 과정에서 필요한 네비게이션 처리를 지원합니다.


210-211: 로컬 데이터 정리 시 deviceId와 pushToken 제거

로그아웃/탈퇴 시 푸시 알림 관련 데이터까지 완전히 정리하여 개인정보 보호가 강화됩니다.


306-309: 병렬 데이터 로딩으로 성능 최적화

사용자 정보와 MBTI 추천 정보를 Promise.all로 병렬 로드하여 화면 로딩 시간을 단축합니다.


321-327: 화면 포커스 시 MBTI 별명 갱신

다른 화면에서 돌아올 때마다 최신 별명 정보를 가져와 사용자에게 최신 상태를 제공합니다.


340-350: 스크롤 최적화 및 키보드 대응

keyboardShouldPersistTaps="handled"showsVerticalScrollIndicator={false} 설정으로 사용자 경험이 개선됩니다.


374-383: MBTI 별명 로딩 UI 구현

별명을 불러오는 동안 로딩 인디케이터를 표시하여 사용자에게 진행 상황을 알려줍니다.


425-435: 이벤트 섹션 UI 개선

"진행 중인 이벤트"로 섹션명을 변경하고 룰렛 버튼에 화살표 아이콘을 추가하여 사용자 이해도가 향상됩니다.

Comment on lines +61 to +62
const scrollRef = useRef(null);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

scrollRef가 ScrollView에 연결되지 않아 scrollToEnd가 동작하지 않습니다.

scrollRef를 선언만 하고 ScrollView의 ref로 전달하지 않아, onFocus에서 호출하는 scrollRef.current?.scrollToEnd가 항상 no-op입니다. 아래처럼 ScrollView에 ref를 연결해주세요.

-        <ScrollView
+        <ScrollView
+          ref={scrollRef}
           style={styles.scroll}
           contentContainerStyle={styles.scrollContent}
           keyboardShouldPersistTaps="handled"
         >

추가로, addrCity에서 Enter로 아코디언을 펼친 직후 첫 추가 필드에 포커스 주면 UX가 좋아집니다(렌더 후 포커스).

// 예시: 아코디언이 열리면 첫 필드로 포커스
useEffect(() => {
  if (showMoreAddr) {
    requestAnimationFrame(() => refTown.current?.focus?.());
  }
}, [showMoreAddr]);

Also applies to: 319-325

🤖 Prompt for AI Agents
In src/screens/Auth/SignUp2Screen.js around lines 61-62 (also applies to lines
319-325): scrollRef is only declared and never attached to the ScrollView so
scrollRef.current?.scrollToEnd in onFocus is a no-op; attach scrollRef to the
ScrollView by passing it to the ref prop (e.g., ref={scrollRef}) so scrollToEnd
works, and add a useEffect that watches the accordion open flag (showMoreAddr)
and, when true, schedules focus for the first additional field (refTown) after
render using requestAnimationFrame (ensure refTown is created with useRef and
used on the first extra input).

Comment on lines +161 to +165
const handleBookmark = () => {
Alert.alert("북마크", "이 챕터를 북마크에 추가했습니다!", [
{ text: "확인", onPress: () => setShowMenu(false) }
]);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

북마크 기능이 실제로 구현되지 않음

북마크 추가 알림만 표시하고 실제로 저장하지 않습니다. 사용자가 북마크를 나중에 확인할 수 없어 혼란을 줄 수 있습니다.

북마크 기능을 실제로 구현하거나, 구현 전까지는 이 기능을 주석 처리하는 것이 좋습니다. AsyncStorage나 서버 API를 통해 북마크를 저장하고 관리해야 합니다.

🤖 Prompt for AI Agents
In src/screens/Guide/StudyScreen.js around lines 161 to 165, the handleBookmark
function only shows an alert and does not persist the bookmark; implement real
persistence (or disable until implemented). Update handleBookmark to build a
bookmark object (e.g., chapterId, title, timestamp), then save it to persistent
storage: either add it to AsyncStorage under a bookmarks key (read existing
array, append, write back) or call the backend bookmarks API and handle
success/error; after successful save, show the confirmation Alert and close the
menu; ensure state/UI updates (e.g., isBookmarked flag) and handle errors with
user feedback; if you prefer a temporary fix, remove or comment out the menu
item until persistence is implemented.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/screens/Guide/GuideScreen.js (1)

40-87: 비동기 로딩 중 화면 이탈/토큰 없음 경로에서 setLoading(false) 누락 및 setState after unmount 가능성

클린업 가드(isActive)로 화면 이탈 시 setState 방지하고, 토큰 없음 조기 반환 시 로딩 해제하세요.

   useFocusEffect(
     useCallback(() => {
-      navigation.setOptions({
+      let isActive = true;
+      navigation.setOptions({
         headerTitle: "학습 가이드",
         headerStyle: { backgroundColor: "#003340" },
         headerTintColor: "#c6d4e1",
         headerRight: () => (
           <TouchableOpacity
             onPress={() => navigation.navigate("TutorialScreen", { allowSkip: true })}
             style={{ paddingHorizontal: 12, paddingVertical: 6 }}
             hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
           >
             <Icon name="help-circle" size={22} color="#c6d4e1" />
           </TouchableOpacity>
         ),
       });
 
       const loadAllProgress = async () => {
         setLoading(true);
         const accessToken = await getNewAccessToken(navigation);
         if (!accessToken) {
           Alert.alert("인증 오류", "토큰이 만료되었습니다. 다시 로그인해주세요.");
           navigation.navigate("Login");
+          if (isActive) setLoading(false); // 조기 반환 시 로딩 해제
           return;
         }
 
         try {
-          const map = {};
-          for (const levelId of LEVELS) {
-            const res = await fetch(`${API_BASE_URL}progress/level/${levelId}/`, {
-              method: "GET",
-              headers: { Authorization: `Bearer ${accessToken}` },
-            });
-            if (!res.ok) throw new Error(`Level ${levelId} fetch failed: ${res.status}`);
-            map[levelId] = await res.json();
-          }
-          setProgressMap(map);
+          const map = {};
+          for (const levelId of LEVELS) {
+            const res = await fetch(`${API_BASE_URL}progress/level/${levelId}/`, {
+              method: "GET",
+              headers: { Authorization: `Bearer ${accessToken}` },
+            });
+            if (!res.ok) throw new Error(`Level ${levelId} fetch failed: ${res.status}`);
+            map[levelId] = await res.json();
+          }
+          if (isActive) setProgressMap(map);
         } catch (err) {
           console.error(err);
-          Alert.alert("데이터 오류", "진행도 정보를 불러오는 중 오류가 발생했습니다.");
+          if (isActive) {
+            Alert.alert("데이터 오류", "진행도 정보를 불러오는 중 오류가 발생했습니다.");
+          }
         } finally {
-          setLoading(false);
+          if (isActive) setLoading(false);
         }
       };
 
       loadAllProgress();
+      return () => {
+        isActive = false;
+      };
     }, [navigation])
   );
♻️ Duplicate comments (2)
src/screens/Guide/GuideScreen.js (1)

199-205: 잠금 상태에서도 이동 발생 — onPress에 차단 분기 추가

UI에 자물쇠가 떠도 네비게이션이 열립니다. 잠금 시 Alert만 노출하고 이동을 막아주세요.

-            const onPress = () => navigation.navigate(`GuideLevel${levelId}`);
+            const onPress = () => {
+              if (levelId !== 1 && !prevComplete) {
+                Alert.alert("잠금", "이전 단계를 완료하면 열립니다.");
+                return;
+              }
+              navigation.navigate(`GuideLevel${levelId}`);
+            };
src/screens/MyPage/MyPageScreen.js (1)

112-135: 생일 D-day 계산 시각 비교 오류(당일 오후 D-365 등) — 날짜 단위 정규화 필요

당일 오후에도 “지난” 것으로 판단될 수 있습니다. 날짜 00:00로 정규화해 비교하세요.

   const calculateBirthdayDday = (birthdate) => {
     if (!birthdate) return "";
-
-    const today = new Date();
-    const birth = new Date(birthdate);
-
-    const thisYearBirthday = new Date(
-      today.getFullYear(),
-      birth.getMonth(),
-      birth.getDate()
-    );
-
-    // 올해 생일이 지났으면 내년 생일로
-    if (thisYearBirthday < today) {
-      thisYearBirthday.setFullYear(today.getFullYear() + 1);
-    }
-    const diffTime = thisYearBirthday - today;
-    const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+    const now = new Date();
+    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // 00:00
+    const b = new Date(birthdate);
+    let thisYearBirthday = new Date(today.getFullYear(), b.getMonth(), b.getDate()); // 00:00
+    if (thisYearBirthday < today) {
+      thisYearBirthday = new Date(today.getFullYear() + 1, b.getMonth(), b.getDate());
+    }
+    const msPerDay = 1000 * 60 * 60 * 24;
+    const diffDays = Math.round((thisYearBirthday - today) / msPerDay);
 
     if (diffDays === 0) return "🎉 생일 축하드려요!";
     if (diffDays === 1) return "🎂 D-1";
     return `🎂 D-${diffDays}`;
   };
🧹 Nitpick comments (16)
src/utils/point.js (4)

20-26: 400 단일 분기는 취약 — 409/429 등도 동일 취지로 처리 고려
일일 제한·중복 사용은 400 외 409/429로도 올 수 있습니다. 상태코드 다중 허용으로 방어적 처리 추천.

적용 예:

-    if (response.status === 400) {
+    if ([400, 409, 429].includes(response.status)) {
       console.log("⏰ 룰렛 이미 사용함 - 하루 한 번 제한");
-      // throw 하지 말고 Promise.reject로 조용히 처리
       return Promise.reject("already_used_today");
     }

28-29: 문자열 에러는 정보 손실 — 에러 코드/메타데이터 구조화
현재 문자열만 반환해 상태코드·분류 확장이 어렵습니다. 비호환 우려가 있다면 유지하되, 가능하면 객체로 표준화하세요.

호환성 유지가 가능할 때:

-      return Promise.reject(`API 호출 실패: ${response.status}`);
+      return Promise.reject({ code: "http_error", status: response.status });

호환이 어려우면 최소한 상태 텍스트를 포함하세요:

-      return Promise.reject(`API 호출 실패: ${response.status}`);
+      return Promise.reject(`API 호출 실패: ${response.status} ${response.statusText || ""}`.trim());

해당 변경 시 호출부에서 문자열/객체 분기 처리 여부를 확인해주세요.


17-19: 로그 레벨/노출 관리
본문 전체 로그는 운영 환경에서 과다 노출 위험이 있습니다. __DEV__ 가드 또는 내부 로거(Level/PII 필터)로 제한하세요.

예:

-    console.log("예수금 추가 응답 본문:", text);
+    __DEV__ && console.log("예수금 추가 응답 본문:", text);

동일하게 오류 로그도 개발 모드에서만 상세 본문을 출력하도록 조정 권장.

Also applies to: 41-44


4-13: 입력값 사전 검증
amount에 대한 유효성(양의 finite number) 체크가 없습니다. 네트워크 호출 전 빠른 실패 처리 권장.

예:

 export const increaseBalance = async (navigation, amount) => {
   try {
     console.log("💰 예수금 추가 요청:", amount);
+    if (!Number.isFinite(amount) || amount <= 0) {
+      return Promise.reject("invalid_amount");
+    }
src/screens/Guide/GuideScreen.js (2)

66-77: 레벨 진행도 API 호출 병렬화로 로딩 체감 개선

순차 호출 대신 Promise.all로 병렬화하면 대기 시간을 줄일 수 있습니다.

-        try {
-          const map = {};
-          for (const levelId of LEVELS) {
-            const res = await fetch(`${API_BASE_URL}progress/level/${levelId}/`, {
-              method: "GET",
-              headers: { Authorization: `Bearer ${accessToken}` },
-            });
-            if (!res.ok) throw new Error(`Level ${levelId} fetch failed: ${res.status}`);
-            map[levelId] = await res.json();
-          }
-          setProgressMap(map);
+        try {
+          const entries = await Promise.all(
+            LEVELS.map(async (levelId) => {
+              const res = await fetch(`${API_BASE_URL}progress/level/${levelId}/`, {
+                method: "GET",
+                headers: { Authorization: `Bearer ${accessToken}` },
+              });
+              if (!res.ok) throw new Error(`Level ${levelId} fetch failed: ${res.status}`);
+              const json = await res.json();
+              return [levelId, json];
+            })
+          );
+          setProgressMap(Object.fromEntries(entries));

121-124: contentContainerStyle 바닥 패딩 매직 넘버 상수화

가독성과 유지보수를 위해 의미 있는 상수로 치환하세요.

-const LEVELS = [1, 2, 3];
+const LEVELS = [1, 2, 3];
+const BOTTOM_SPACER = 56 + 14 + 8; // TODO: 버튼/Divider 여백 합(설명 주석 추가 권장)
@@
-            paddingBottom: tabBarHeight + Math.max(insets.bottom, 0) + 56 + 14 + 8,
+            paddingBottom: tabBarHeight + Math.max(insets.bottom, 0) + BOTTOM_SPACER,

Also applies to: 26-32

src/screens/Auth/FindPasswordScreen.js (6)

9-16: 불필요한 Dimensions 제거

Dimensions를 import/해상도 추출하지만 사용하지 않습니다. 제거해 번들 크기·경고를 줄이세요.

   StyleSheet,
-  ScrollView,
-  Dimensions,
+  ScrollView,
   ActivityIndicator,
   KeyboardAvoidingView,
   Platform,
   SafeAreaView,
   Keyboard,
 } from "react-native";
@@
-const { height, width } = Dimensions.get("window");

Also applies to: 21-21


139-156: OTP 입력 UX 개선: 붙여넣기/자동완성/백스페이스 지원

여러 자리 붙여넣기, iOS/Android OTP 자동완성(one-time-code), 빈 칸에서 백스페이스 시 이전 칸으로 이동을 지원하면 실사용성이 크게 개선됩니다.

-  const handleCodeChange = (text, index) => {
-    if (/^\d$/.test(text)) {
-      const newCode = [...code];
-      newCode[index] = text;
-      setCode(newCode);
-
-      if (index < 5) {
-        codeInputs.current[index + 1].focus();
-      } else {
-        // 6자리 입력 완료
-        codeInputs.current[index].blur();
-      }
-    } else if (text === "") {
-      const newCode = [...code];
-      newCode[index] = "";
-      setCode(newCode);
-    }
-  };
+  const handleCodeChange = (text, index) => {
+    const digits = (text || "").replace(/\D/g, "");
+    if (digits.length === 0) {
+      const next = [...code];
+      next[index] = "";
+      setCode(next);
+      return;
+    }
+    const next = [...code];
+    for (let i = 0; i < digits.length && index + i < 6; i++) {
+      next[index + i] = digits[i];
+    }
+    setCode(next);
+    const lastFilled = Math.min(index + digits.length, 6) - 1;
+    if (lastFilled < 5) {
+      codeInputs.current[lastFilled + 1]?.focus();
+    } else {
+      codeInputs.current[5]?.blur();
+    }
+  };
@@
-                  <TextInput
+                  <TextInput
                     key={index}
                     ref={(ref) => (codeInputs.current[index] = ref)}
-                    style={styles.codeInput}
+                    style={[styles.codeInput, index < 5 && styles.codeInputMargin]}
                     value={digit}
                     onChangeText={(text) => handleCodeChange(text, index)}
                     keyboardType="number-pad"
+                    inputMode="numeric"
+                    textContentType="oneTimeCode"
+                    autoComplete="one-time-code"
+                    onKeyPress={({ nativeEvent }) => {
+                      if (nativeEvent.key === "Backspace" && !code[index] && index > 0) {
+                        codeInputs.current[index - 1]?.focus();
+                        handleCodeChange("", index - 1);
+                      }
+                    }}
                     maxLength={1}
                     textAlign="center"
                   />

Also applies to: 299-309


271-279: 이메일 입력 정규화 및 힌트 강화

불필요한 공백으로 인한 실수를 줄이고, 자동완성 힌트를 추가하세요.

                 keyboardType="email-address"
                 autoCapitalize="none"
+                autoComplete="email"
+                textContentType="emailAddress"
+                autoCorrect={false}
                 value={email}
-                onChangeText={(t) => setEmail((t || "").trimStart())}
+                onChangeText={(t) => setEmail((t || "").trim())}
                 returnKeyType="done"

325-336: 비밀번호 필드 입력 힌트 강화

iOS/Android 자동완성 신뢰도 향상과 자동 대문자화를 방지하세요.

                 <TextInput
                   ref={refNewPw}
                   style={styles.inputField}
                   placeholder="새 비밀번호 입력"
                   placeholderTextColor="#bcd1d6"
                   secureTextEntry={seeNewPassword}
                   value={newPassword}
                   onChangeText={setNewPassword}
+                  textContentType="newPassword"
+                  autoComplete="new-password"
+                  autoCapitalize="none"
                   returnKeyType="next"
                   onSubmitEditing={() => refConfirmPw.current && refConfirmPw.current.focus()}
                 />
@@
                 <TextInput
                   ref={refConfirmPw}
                   style={styles.inputField}
                   placeholder="새 비밀번호 확인"
                   placeholderTextColor="#bcd1d6"
                   secureTextEntry={seeConfirmPassword}
                   value={confirmPassword}
                   onChangeText={setConfirmPassword}
+                  textContentType="newPassword"
+                  autoComplete="new-password"
+                  autoCapitalize="none"
                   returnKeyType="done"
                   onSubmitEditing={handleResetPassword}
                 />

Also applies to: 361-371


312-318: 재전송 스팸 방지

로딩 중엔 재전송 버튼을 비활성화하세요. (이미 handleRequestCode가 가드하지만 UX 측면에서 명시가 좋습니다.)

               <TouchableOpacity
                 style={styles.resendButton}
-                onPress={handleResendCode}
+                onPress={handleResendCode}
+                disabled={isLoading}
               >

69-78: 검증/강도 로직 중복 제거 제안

validateEmail/passwordValid/passwordStrength가 SignUp2 등과 중복됩니다. utils로 추출해 단일 소스로 관리하면 유지보수성이 올라갑니다.

원하시면 공용 유틸(예: src/utils/validators.ts)로 리팩터 PR 초안 드릴게요.

Also applies to: 81-97

package.json (1)

7-8: run: 전환 OK — dev client 스크립트도 함께 두세요*

SDK 53에서 Android의 Expo Go는 원격 푸시를 지원하지 않으므로(dev build 필요) expo run:android/ios 채택은 타당합니다. 개발 루프 효율을 위해 개발 빌드와 짝지어 쓰는 expo start --dev-client 스크립트 추가를 권장합니다. 이는 디바이스에 설치된 dev client로 즉시 연결해 재빌드 빈도를 줄입니다.

참고: SDK 53에서 Android Expo Go 푸시 비지원 공지와 dev build 요구사항. (docs.expo.dev)

예시 스크립트(추가 제안):

{
  "scripts": {
    "dev:client": "expo start --dev-client",
    "android:dev": "expo run:android && expo start --dev-client",
    "ios:dev": "expo run:ios && expo start --dev-client"
  }
}
src/screens/MyPage/MyPageScreen.js (3)

85-89: catch 로그 문구/레벨 수정 — ‘완료’가 아닌 ‘오류’

예외 블록에서 “완료”로 로깅하고 있습니다. 에러로 기록하고 메시지도 정정하세요.

-    } catch (error) {
-  console.log("ℹ️ MBTI 추천 정보 가져오기 완료:", error.message || error);
-} finally {
+    } catch (error) {
+      console.error("❌ MBTI 추천 정보 가져오기 오류:", error?.message || error);
+    } finally {

389-399: 이메일 개인정보 노출 — 마스킹 적용 권장

UI에 원문 이메일을 노출합니다. maskEmail을 활용해 최소한의 가림 처리를 권장합니다.

-                    <Text style={styles.userDetailText}>
-                      {userInfo.email /* 마스킹은 필요 시 maskEmail 사용 */}
-                    </Text>
+                    <Text style={styles.userDetailText}>
+                      {maskEmail(userInfo.email)}
+                    </Text>

279-281: 탈퇴 후 뒤로가기로 복귀 가능 — 스택 리셋 권장

navigate("Login")만 사용하면 백스택으로 복귀할 수 있습니다. 삭제 이후에는 스택을 초기화하세요.

-              Alert.alert("탈퇴 완료", "계정이 삭제되었습니다.");
-              navigation.navigate("Login");
+              Alert.alert("탈퇴 완료", "계정이 삭제되었습니다.", [
+                {
+                  text: "확인",
+                  onPress: () =>
+                    navigation.reset({ index: 0, routes: [{ name: "Login" }] }),
+                },
+              ]);
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fc9d8cf and c14c636.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (7)
  • app.json (3 hunks)
  • package.json (2 hunks)
  • src/screens/Auth/FindPasswordScreen.js (1 hunks)
  • src/screens/Guide/GuideScreen.js (5 hunks)
  • src/screens/MyPage/MyPageScreen.js (9 hunks)
  • src/screens/MyPage/RouletteScreen.js (3 hunks)
  • src/utils/point.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app.json
🧰 Additional context used
🧬 Code graph analysis (5)
src/utils/point.js (3)
src/utils/apiConfig.js (2)
  • response (69-75)
  • data (81-81)
src/utils/token.js (3)
  • response (27-31)
  • response (82-86)
  • response (181-188)
src/utils/account.js (4)
  • response (10-16)
  • data (27-27)
  • text (19-19)
  • navigation (6-54)
src/screens/Auth/FindPasswordScreen.js (5)
src/screens/Auth/SignUp2Screen.js (12)
  • Dimensions (22-22)
  • email (28-28)
  • confirmPassword (30-30)
  • scrollRef (61-61)
  • keyboardVisible (64-64)
  • keyboardHeight (65-65)
  • bottomSpacer (83-86)
  • validateEmail (93-94)
  • passwordValid (99-105)
  • passwordStrength (108-121)
  • strengthText (297-297)
  • styles (578-700)
src/screens/Auth/SignUp3Screen.js (2)
  • code (15-15)
  • styles (98-142)
src/screens/Auth/ResetPasswordScreen.js (4)
  • newPassword (17-17)
  • confirmPassword (18-18)
  • handleResetPassword (21-87)
  • styles (165-263)
src/components/EyeClosed.js (1)
  • EyeClosed (4-15)
src/components/EyeOpen.js (1)
  • EyeOpen (4-11)
src/screens/MyPage/RouletteScreen.js (1)
src/utils/point.js (2)
  • increaseBalance (4-52)
  • increaseBalance (4-52)
src/screens/MyPage/MyPageScreen.js (5)
src/utils/mbtiType.js (5)
  • fetchUserMbtiType (5-50)
  • fetchUserMbtiType (5-50)
  • response (9-15)
  • data (27-27)
  • text (17-17)
src/utils/token.js (9)
  • accessToken (7-7)
  • accessToken (129-129)
  • accessToken (171-171)
  • getNewAccessToken (126-153)
  • getNewAccessToken (126-153)
  • response (27-31)
  • response (82-86)
  • response (181-188)
  • email (68-68)
src/services/PushNotificationService.js (5)
  • accessToken (80-80)
  • response (93-104)
  • response (160-168)
  • unregisterPushToken (151-191)
  • unregisterPushToken (151-191)
src/utils/apiConfig.js (4)
  • response (69-75)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
  • data (81-81)
src/utils/user.js (7)
  • response (10-16)
  • response (53-60)
  • data (27-27)
  • text (19-19)
  • text (63-63)
  • fetchUserInfo (6-46)
  • fetchUserInfo (6-46)
src/screens/Guide/GuideScreen.js (4)
src/utils/apiConfig.js (2)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
src/utils/token.js (3)
  • accessToken (7-7)
  • accessToken (129-129)
  • accessToken (171-171)
src/screens/Guide/TutorialScreen.js (2)
  • styles (172-221)
  • TutorialScreen (47-170)
src/components/LearningProgressBar.js (2)
  • styles (24-54)
  • LearningProgressBar (7-22)
🔇 Additional comments (6)
src/screens/Guide/GuideScreen.js (2)

42-55: 헤더 구성 및 헬프 아이콘 내비게이션 깔끔합니다

포커스 시 setOptions로 제목/색상/우측 도움말 이동을 일관되게 설정한 점 좋습니다.


135-137: 아이콘 경로 확인 완료 — 파일 존재함
src/screens/Guide/GuideScreen.js의 require('../../assets/icons/question.png')가 리포지터리 내 src/assets/icons/question.png로 확인됨 — 번들 실패 없음.

src/screens/Auth/FindPasswordScreen.js (2)

49-61: 키보드 리스너/Spacer 계산 LGTM

클린업 포함, 하단 Spacer 계산도 합리적입니다.

Also applies to: 63-67


482-499: gap 호환성 검토 — RN 0.71+에서 지원(조건부), Expo/아키텍처·버전 의존 — 마진 폴백 권장

Location: src/screens/Auth/FindPasswordScreen.js (lines 482–499)

요약: React Native는 0.71부터 flex gap을 네이티브로 도입했으나(0.75에서 % 지원 등 변경 포함) Expo는 사용하는 SDK가 바인딩한 RN 버전에 따르며 초기 구현에서 wrap 관련 렌더/레이아웃 버그가 보고되어 기기별 테스트가 필요합니다.

  • 확인: 프로젝트의 Expo SDK / React Native 버전 알려주세요. RN >= 0.71이면 gap 유지 가능(기기 테스트 권장).
  • 권장(최대 호환성): RN < 0.71 또는 안정성 우선이면 margin 폴백 적용 — 제안 diff:
   codeContainer: {
     flexDirection: "row",
     justifyContent: "center",
-    gap: 8,
     marginBottom: 20,
   },
   codeInput: {
     width: 45,
     height: 50,
@@
     backgroundColor: "#f1f6f7",
     textAlign: "center",
   },
+  codeInputMargin: { marginRight: 8 },
package.json (1)

22-22: expo-notifications 고정(~0.31.4) 유지 — Android 푸시는 dev build에서만 동작하므로 dev build에서 검증 필요

~0.31.4는 SDK 53에 맞습니다. 다만 Android의 Expo Go는 원격 푸시를 지원하지 않으므로 권한 요청·토큰 발급·푸시 수신 흐름을 실제 기기/에뮬레이터에서 dev build로 검증해야 합니다.

스크립트 실행 결과: repo에 expo가 설치되어 있지 않아 npx expo-doctor가 SDK 버전을 확인하지 못했습니다 ("Cannot determine the project's Expo SDK version because the module expo is not installed."). 조치: repo에 expo 설치(npm install expo 또는 yarn add expo) 후 npx expo-doctor 재실행 및 Android dev build에서 푸시 플로우 검증.

File: package.json (라인 22): "expo-notifications": "~0.31.4",

src/screens/MyPage/MyPageScreen.js (1)

151-151: unregisterPushToken 호출 인자 제거 — 확인 필요

레포에서 선언부/호출부 검색이 되지 않아 시그니처를 확인하지 못했습니다. 함수가 인자를 받지 않는다면 호출부에서 navigation 인자를 제거하세요.

File: src/screens/MyPage/MyPageScreen.js (lines 151, 247)

-        const pushUnregisterSuccess = await unregisterPushToken(navigation);
+        const pushUnregisterSuccess = await unregisterPushToken();
-              await unregisterPushToken(navigation);
+              await unregisterPushToken();

"expo": "~53.0.0",
"expo-asset": "~11.1.7",
"expo-constants": "^17.1.7",
"expo-constants": "*",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

expo-constants를 '*'로 두면 SDK 호환 깨질 수 있음 — SDK 53 번들 범위로 고정 권장

Expo 패키지는 SDK 버전에 맞춰 엄격히 호환됩니다. expo-constants의 SDK 53 번들 버전은 ~17.1.7이므로 와일드카드(*) 대신 해당 범위로 고정하거나 npx expo install expo-constants로 정합 버전을 적용하세요.

근거: SDK 53 문서의 상응 버전 및 Constants 문서의 번들 버전 표기. (docs.expo.dev)

제안 diff:

-    "expo-constants": "*",
+    "expo-constants": "~17.1.7",

추가로 expo-font, expo-linking, expo-splash-screen 등도 * 대신 expo install을 통해 SDK 53 호환 범위로 정렬해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "PWD: $(pwd)"
echo "---- locate package.json (root) ----"
if [ -f package.json ]; then
  echo "Found package.json at repo root. Showing (1-240):"
  nl -ba package.json | sed -n '1,240p'
else
  echo "No package.json at repo root. Searching up to depth 4 for package.json files..."
  find . -maxdepth 4 -name package.json -print -exec nl -ba {} \; || true
fi
echo "---- grep for expo-constants occurrences ----"
rg -n --hidden '"expo-constants"\s*:\s*"[^"]*"' -S || true
echo "---- grep for expo dependency/version ----"
rg -n --hidden '"expo"\s*:\s*"[^"]*"' -S package.json || true
echo "---- check app.json/app.config.* for sdkVersion ----"
rg -n --hidden '"sdkVersion"\s*:\s*"[^"]*"' -S || true
rg -n --hidden '"sdkVersion"\s*:\s*"[^"]*"' -S app.json app.config.* || true

Length of output: 151


추가 검증 스크립트를 실행합니다.


🏁 Script executed:

#!/bin/bash
set -u -o pipefail

echo "PWD: $(pwd)"
echo "---- package.json (up to 400 lines) ----"
if [ -f package.json ]; then
  echo "== package.json =="
  sed -n '1,400p' package.json || true
else
  echo "No package.json at repo root. Searching up to depth 4..."
  find . -maxdepth 4 -name package.json -print -exec sh -c 'echo "== {} =="; sed -n "1,400p" {}' \; || true
fi

echo
echo "---- package.json: deps with '*' and expo-related entries ----"
if [ -f package.json ]; then
  if command -v rg >/dev/null 2>&1; then
    rg -n --hidden -S '"[^"]*"\s*:\s*"\*"' package.json || true
    rg -n --hidden -S '"expo[^"]*"\s*:\s*"[^"]*"' package.json || true
  else
    grep -nP '"[^"]*"\s*:\s*"\*"' package.json || true
    grep -nP '"expo[^"]*"\s*:\s*"[^"]*"' package.json || true
  fi
fi

echo
echo "---- repo-wide search for specific expo packages ----"
PKGS="expo-constants expo-font expo-linking expo-splash-screen expo"
for p in $PKGS; do
  echo "--- $p ---"
  if command -v rg >/dev/null 2>&1; then
    rg -n --hidden "\"$p\"\s*:\s*\"[^\"]*\"" -S || true
  else
    grep -RIn --exclude-dir=node_modules --exclude-dir=.git "\"$p\"[[:space:]]*:[[:space:]]*\"" || true
  fi
done

echo
echo "---- check app.json / app.config.* for sdkVersion ----"
for f in app.json app.config.js app.config.cjs app.config.ts app.config.mjs; do
  if [ -f "$f" ]; then
    echo "== $f =="
    sed -n '1,240p' "$f" || true
  fi
done

Length of output: 4807


expo-constants을 '*'로 두면 SDK 호환성 깨짐 — 즉시 버전 고정 필요

프로젝트는 "expo": "~53.0.0"을 사용하므로 expo-constants는 SDK 53 번들(~17.1.7)로 고정하세요 — package-lock.json에 "~17.1.7"이 이미 기록되어 있습니다. 파일: package.json (dependencies).

제안 diff:

-    "expo-constants": "*",
+    "expo-constants": "~17.1.7",

expo-font, expo-linking, expo-splash-screen 등 '*'로 지정된 다른 Expo 패키지도 npx expo install <pkg>로 SDK 53 호환 범위로 정렬하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"expo-constants": "*",
"expo-constants": "~17.1.7",
🤖 Prompt for AI Agents
In package.json around line 18, expo-constants is set to "*" which breaks SDK
compatibility; change it to the SDK-53 compatible version (~17.1.7)
(package-lock.json already has ~17.1.7) and update package.json accordingly,
then run npm install to update lockfile; also run npx expo install for other
Expo deps currently pinned as "*" (e.g., expo-font, expo-linking,
expo-splash-screen) so they are set to versions compatible with "expo":
"~53.0.0".

Comment on lines +118 to +130
const data = await response.json();
console.log("✅ 인증번호 요청 응답:", data);

if (response.status === 200 || data.message) {
Alert.alert(
"인증번호 전송",
"이메일로 인증번호가 전송되었습니다.\n6자리 인증번호를 입력해주세요.",
[{ text: "확인", onPress: () => setStep(2) }]
);
} else {
Alert.alert("오류", data.message || "인증번호 전송에 실패했습니다.");
}
} catch (error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

요청 성공 판정이 잘못되어 실패 응답도 성공 처리될 수 있음

response.status === 200 || data.message 조건은 오류 응답(에러 메시지 포함)을 성공으로 오인할 수 있습니다. 또한 JSON 이외 응답에서 json()가 throw될 수 있습니다. response.ok 기반으로 판정하고 content-type을 확인하세요. 성공 시 첫 입력에 포커스까지 주면 UX가 좋아집니다.

-      const data = await response.json();
-      console.log("✅ 인증번호 요청 응답:", data);
-
-      if (response.status === 200 || data.message) {
+      const contentType = response.headers.get("content-type") || "";
+      const data = contentType.includes("application/json") ? await response.json() : await response.text();
+      if (__DEV__) console.log("✅ 인증번호 요청 응답:", data);
+
+      if (response.ok || (typeof data === "object" && data?.status === "success")) {
         Alert.alert(
           "인증번호 전송",
           "이메일로 인증번호가 전송되었습니다.\n6자리 인증번호를 입력해주세요.",
-          [{ text: "확인", onPress: () => setStep(2) }]
+          [{ text: "확인", onPress: () => {
+              setStep(2);
+              setTimeout(() => codeInputs.current?.[0]?.focus(), 0);
+            }}]
         );
       } else {
-        Alert.alert("오류", data.message || "인증번호 전송에 실패했습니다.");
+        const msg = typeof data === "object" ? (data.message || data.detail) : String(data || "");
+        Alert.alert("오류", msg || "인증번호 전송에 실패했습니다.");
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = await response.json();
console.log("✅ 인증번호 요청 응답:", data);
if (response.status === 200 || data.message) {
Alert.alert(
"인증번호 전송",
"이메일로 인증번호가 전송되었습니다.\n6자리 인증번호를 입력해주세요.",
[{ text: "확인", onPress: () => setStep(2) }]
);
} else {
Alert.alert("오류", data.message || "인증번호 전송에 실패했습니다.");
}
} catch (error) {
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json") ? await response.json() : await response.text();
if (__DEV__) console.log("✅ 인증번호 요청 응답:", data);
if (response.ok || (typeof data === "object" && data?.status === "success")) {
Alert.alert(
"인증번호 전송",
"이메일로 인증번호가 전송되었습니다.\n6자리 인증번호를 입력해주세요.",
[{ text: "확인", onPress: () => {
setStep(2);
setTimeout(() => codeInputs.current?.[0]?.focus(), 0);
}}]
);
} else {
const msg = typeof data === "object" ? (data.message || data.detail) : String(data || "");
Alert.alert("오류", msg || "인증번호 전송에 실패했습니다.");
}
} catch (error) {
🤖 Prompt for AI Agents
In src/screens/Auth/FindPasswordScreen.js around lines 118 to 130, the current
success check uses "response.status === 200 || data.message" which can treat
error responses with a message as success and also can throw when
response.json() is called on non-JSON; change the logic to first check
response.ok, verify Content-Type includes "application/json" before calling
response.json(), and only treat success when response.ok is true (use
data.message for displaying server message on failure). On success, advance to
step 2 and programmatically focus the OTP input so the user can immediately type
the code. Ensure errors from json parsing are caught and surface a generic error
alert if response is not JSON.

Comment on lines +164 to +167
if (enteredCode.length !== 6) {
Alert.alert("오류", "6자리 인증번호를 모두 입력해주세요.");
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

검증 로직 강화: 코드 형식·응답 파싱/성공 판정 보강

6자리 숫자 형식 검증과 JSON 이외 응답 대비, 성공 조건을 response.ok로 정교화하세요. dev 로그만 남기도록 가드도 추가했습니다.

-    if (enteredCode.length !== 6) {
+    if (!/^\d{6}$/.test(enteredCode)) {
       Alert.alert("오류", "6자리 인증번호를 모두 입력해주세요.");
       return;
     }
@@
-      const data = await response.json();
-      console.log("✅ 비밀번호 찾기 응답:", data);
-
-      if (response.status === 200 || data.status === "success") {
+      const contentType = response.headers.get("content-type") || "";
+      const data = contentType.includes("application/json") ? await response.json() : await response.text();
+      if (__DEV__) console.log("✅ 비밀번호 찾기 응답:", data);
+
+      if (response.ok || (typeof data === "object" && data?.status === "success")) {
         Alert.alert(
           "성공",
           "비밀번호가 성공적으로 변경되었습니다.\n새 비밀번호로 로그인해주세요.",
           [{ text: "확인", onPress: () => navigation.navigate("Login") }]
         );
       } else {
-        Alert.alert("오류", data.message || "비밀번호 재설정에 실패했습니다.");
+        const msg = typeof data === "object" ? (data.message || data.detail) : String(data || "");
+        Alert.alert("오류", msg || "비밀번호 재설정에 실패했습니다.");
         // 인증번호 초기화
         setCode(["", "", "", "", "", ""]);
         if (codeInputs.current[0]) {
           codeInputs.current[0].focus();
         }
       }

Also applies to: 192-205

🤖 Prompt for AI Agents
In src/screens/Auth/FindPasswordScreen.js around lines 164-167 (and similarly
for lines 192-205): strengthen the verification and response handling by first
validating enteredCode is exactly six digits (use a /^\d{6}$/ regex and return
with Alert if it fails), then when calling the API check response.ok to
determine success, parse response JSON inside a try/catch to handle non-JSON
responses safely, and on error/failure show a user-facing Alert; finally wrap
any debug console.logs with a __DEV__ guard so only dev logs remain in
development.

Comment on lines +192 to +221
const data = progressMap[levelId] || {
completed: 0,
total: 0,
is_level_completed: false,
progress_ratio: "0/0",
};

const prevComplete = levelId === 1 || progressMap[levelId - 1]?.is_level_completed;
const showLockIcon = !prevComplete;

const label = `${levelId}단계`;
const onPress = () => navigation.navigate(`GuideLevel${levelId}`);

return (
<View key={levelId} style={styles.levelBlock}>
{data.is_level_completed ? (
<ClearButton label={label} onPress={onPress} />
) : (
<UnClearButton onPress={onPress}>
<View style={styles.labelWithIcon}>
<Text style={styles.menuText}>{label}</Text>
{showLockIcon && <LockIcon style={styles.lockIcon} width={20} height={20} />}
</View>
</UnClearButton>
)}

<LearningProgressBar current={data.completed} total={data.total} />
</View>
);
})}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

total=0일 때 진행도 바 NaN/Infinity 위험 — 안전 가드 추가

백엔드가 total 0을 줄 경우 LearningProgressBar의 flex 계산이 NaN/Infinity가 될 수 있습니다. 안전값으로 보정하세요.

           {LEVELS.map((levelId) => {
             const data = progressMap[levelId] || {
               completed: 0,
               total: 0,
               is_level_completed: false,
               progress_ratio: "0/0",
             };
 
+            // ProgressBar 안전값 보정 (total <= 0 방지)
+            const safeCompleted = Number.isFinite(data.completed) ? Math.max(0, data.completed || 0) : 0;
+            const safeTotal = Number.isFinite(data.total) && data.total > 0 ? data.total : 1;
+
             const prevComplete = levelId === 1 || progressMap[levelId - 1]?.is_level_completed;
             const showLockIcon = !prevComplete;
 
             const label = `${levelId}단계`;
             const onPress = () => navigation.navigate(`GuideLevel${levelId}`);
@@
-                <LearningProgressBar current={data.completed} total={data.total} />
+                <LearningProgressBar current={safeCompleted} total={safeTotal} />

선호안: LearningProgressBar 자체를 견고화

// src/components/LearningProgressBar.js
const progress = total > 0 && Number.isFinite(current)
  ? Math.min(current / total, 1)
  : 0;
🤖 Prompt for AI Agents
In src/screens/Guide/GuideScreen.js around lines 192 to 221, the
LearningProgressBar can receive total=0 from the backend which leads to
NaN/Infinity in its internal flex calculation; update the LearningProgressBar
component (src/components/LearningProgressBar.js) to defensively compute
progress: coerce current and total to numbers, treat any non-finite or total <=
0 as progress = 0, otherwise compute progress = Math.min(current / total, 1);
use this progress value for style calculations to avoid divide-by-zero and clamp
to [0,1]. Ensure the component still accepts numeric props and falls back to 0
when props are missing or invalid.

Comment on lines 29 to 38
const prizes = [
'10 만원',
'5 만원',
'3 만원',
'20 만원',
'30 만원',
'3 만원',
'15 만원',
'8 만원',
{ amount: '10만원', value: 100000, display: '10만' },
{ amount: '5만원', value: 50000, display: '5만' },
{ amount: '3만원', value: 30000, display: '3만' },
{ amount: '20만원', value: 200000, display: '20만' },
{ amount: '30만원', value: 300000, display: '30만' },
{ amount: '3만원', value: 30000, display: '3만' },
{ amount: '15만원', value: 150000, display: '15만' },
{ amount: '8만원', value: 80000, display: '8만' },
];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

클라이언트가 당첨 금액·결과를 결정 — 중대한 보안/무결성 취약점

현재 클라이언트가 idx를 랜덤 산출하고, 그에 따라 prize.value를 서버에 전달하여 예수금을 증가시키는 구조입니다. 조작된 앱/디버깅으로 임의 금액을 전송해도 서버가 이를 식별하기 어렵습니다. 결과 산정, 일일 제한, 증액은 반드시 서버가 전담해야 합니다. 권장 흐름:

  • POST /roulette/spin 요청 → 서버가 당첨 인덱스/금액을 결정하고 일일 제한 검증
  • 서버가 금액 증액까지 처리(또는 서명된 증빙 토큰 발급)
  • 클라이언트는 응답 기반으로 회전 각도/알림만 표시

즉시 적용 어려우면 최소한 서버 응답으로 ‘당첨 인덱스/금액/서명’을 받아 검증 후 표시하세요.

Also applies to: 103-116, 117-123

Comment on lines +73 to +95
// 펄스 애니메이션 효과
React.useEffect(() => {
const pulse = () => {
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!spinning) pulse();
});
};
pulse();
}, [spinning]);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

펄스 애니메이션 클린업 없음 — 언마운트 시 루프 지속 가능

Unmount 시 애니메이션 루프를 중단하지 않아 리소스 낭비/경고 가능성이 있습니다. 정리 함수와 취소 플래그를 추가하세요.

   // 펄스 애니메이션 효과
   React.useEffect(() => {
-    const pulse = () => {
+    let cancelled = false;
+    const pulse = () => {
+      if (cancelled) return;
       Animated.sequence([
         Animated.timing(pulseAnim, {
           toValue: 1.05,
           duration: 1000,
           easing: Easing.inOut(Easing.quad),
           useNativeDriver: true,
         }),
         Animated.timing(pulseAnim, {
           toValue: 1,
           duration: 1000,
           easing: Easing.inOut(Easing.quad),
           useNativeDriver: true,
         }),
       ]).start(() => {
-        if (!spinning) pulse();
+        if (!spinning && !cancelled) pulse();
       });
     };
     pulse();
-  }, [spinning]);
+    return () => {
+      cancelled = true;
+      pulseAnim.stopAnimation(() => pulseAnim.setValue(1));
+    };
+  }, [spinning, pulseAnim]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 펄스 애니메이션 효과
React.useEffect(() => {
const pulse = () => {
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!spinning) pulse();
});
};
pulse();
}, [spinning]);
// 펄스 애니메이션 효과
React.useEffect(() => {
let cancelled = false;
const pulse = () => {
if (cancelled) return;
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!spinning && !cancelled) pulse();
});
};
pulse();
return () => {
cancelled = true;
pulseAnim.stopAnimation(() => pulseAnim.setValue(1));
};
}, [spinning, pulseAnim]);
🤖 Prompt for AI Agents
In src/screens/MyPage/RouletteScreen.js around lines 73 to 95, the pulse
animation effect never cleans up so it can keep looping after unmount; add a
cleanup to stop the loop: introduce a cancelled (or isMounted) flag and check it
before recursing pulse(), and on cleanup set the flag and call
pulseAnim.stopAnimation() (or stop any running animations) to cancel the current
sequence; return this cleanup function from the useEffect so the animation loop
is halted when spinning changes or the component unmounts.

Comment on lines +123 to +130
} catch (error) {
// 모든 에러를 조용히 처리
Alert.alert(
'⏰ 오늘의 기회 소진',
'룰렛은 하루에 한 번만 도전 가능합니다.\n내일 다시 도전해보세요! 🍀',
[{ text: '확인', style: 'default' }]
);
} finally {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

에러별 안내 분기 추가 필요 — 네트워크/파싱 오류까지 ‘기회 소진’으로 표시됨

increaseBalance"already_used_today" | "network_error" | "parsing_error"를 리턴할 수 있습니다. 에러 유형별로 다른 안내를 보여주세요.

-      } catch (error) {
-        // 모든 에러를 조용히 처리
-        Alert.alert(
-          '⏰ 오늘의 기회 소진', 
-          '룰렛은 하루에 한 번만 도전 가능합니다.\n내일 다시 도전해보세요! 🍀', 
-          [{ text: '확인', style: 'default' }]
-        );
+      } catch (error) {
+        const code = (error && error.toString && error.toString()) || error;
+        if (code === 'already_used_today') {
+          Alert.alert('⏰ 오늘의 기회 소진', '룰렛은 하루에 한 번만 도전 가능합니다.\n내일 다시 도전해보세요! 🍀', [{ text: '확인', style: 'default' }]);
+        } else if (code === 'network_error') {
+          Alert.alert('네트워크 오류', '네트워크 상태를 확인한 뒤 다시 시도해주세요.', [{ text: '확인', style: 'default' }]);
+        } else if (code === 'parsing_error') {
+          Alert.alert('일시적 오류', '서버 응답 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.', [{ text: '확인', style: 'default' }]);
+        } else {
+          Alert.alert('오류', '요청 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.', [{ text: '확인', style: 'default' }]);
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
// 모든 에러를 조용히 처리
Alert.alert(
'⏰ 오늘의 기회 소진',
'룰렛은 하루에 한 번만 도전 가능합니다.\n내일 다시 도전해보세요! 🍀',
[{ text: '확인', style: 'default' }]
);
} finally {
} catch (error) {
const code = (error && error.toString && error.toString()) || error;
if (code === 'already_used_today') {
Alert.alert('⏰ 오늘의 기회 소진', '룰렛은 하루에 한 번만 도전 가능합니다.\n내일 다시 도전해보세요! 🍀', [{ text: '확인', style: 'default' }]);
} else if (code === 'network_error') {
Alert.alert('네트워크 오류', '네트워크 상태를 확인한 뒤 다시 시도해주세요.', [{ text: '확인', style: 'default' }]);
} else if (code === 'parsing_error') {
Alert.alert('일시적 오류', '서버 응답 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.', [{ text: '확인', style: 'default' }]);
} else {
Alert.alert('오류', '요청 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.', [{ text: '확인', style: 'default' }]);
}
} finally {
🤖 Prompt for AI Agents
In src/screens/MyPage/RouletteScreen.js around lines 123 to 130, the catch block
currently shows a generic "used today" alert for all errors; instead, inspect
the thrown error (it may be the string "already_used_today", "network_error", or
"parsing_error" or an Error object) and branch alerts accordingly: if
"already_used_today" show the existing message, if "network_error" show a
network-specific alert asking the user to check their connection and retry, if
"parsing_error" show a message indicating a temporary server/data issue and to
try again later; include a default generic error alert for any other/unknown
errors and ensure you still run the finally block as before.

Comment on lines 32 to 39
const data = JSON.parse(text);

if (data.status === "success") {
return data.message;
} else {
throw new Error(data.message || "알 수 없는 오류 발생");
console.log("❌ 서버 에러:", data.message);
return Promise.reject(data.message || "알 수 없는 오류 발생");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

JSON 파싱/빈 본문 가드 없음 — null 접근으로 런타임 예외 가능
빈 응답 또는 null/비객체 JSON에서 data.status 접근 시 예외가 나고, 현재 catch에서 잘못 network_error로 분류될 여지가 큽니다. 파싱·빈 본문을 명시적으로 분기하세요.

제안 패치:

-    const data = JSON.parse(text);
-
-    if (data.status === "success") {
-      return data.message;
-    } else {
-      console.log("❌ 서버 에러:", data.message);
-      return Promise.reject(data.message || "알 수 없는 오류 발생");
-    }
+    if (!text) {
+      console.log("❗ 빈 응답 본문");
+      return Promise.reject("empty_response");
+    }
+    let data = null;
+    try {
+      data = JSON.parse(text);
+    } catch (e) {
+      console.log("JSON 파싱 실패:", e);
+      return Promise.reject("parsing_error");
+    }
+
+    if (data && data.status === "success") {
+      return data.message;
+    }
+    const msg = data?.message ?? data?.error ?? "알 수 없는 오류 발생";
+    console.log("❌ 서버 에러:", msg);
+    return Promise.reject(msg);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = JSON.parse(text);
if (data.status === "success") {
return data.message;
} else {
throw new Error(data.message || "알 수 없는 오류 발생");
console.log("❌ 서버 에러:", data.message);
return Promise.reject(data.message || "알 수 없는 오류 발생");
}
if (!text) {
console.log("❗ 빈 응답 본문");
return Promise.reject("empty_response");
}
let data = null;
try {
data = JSON.parse(text);
} catch (e) {
console.log("JSON 파싱 실패:", e);
return Promise.reject("parsing_error");
}
if (data && data.status === "success") {
return data.message;
}
const msg = data?.message ?? data?.error ?? "알 수 없는 오류 발생";
console.log("❌ 서버 에러:", msg);
return Promise.reject(msg);
🤖 Prompt for AI Agents
In src/utils/point.js around lines 32 to 39, the code parses JSON and directly
accesses data.status which can throw if the response is empty, null, or not an
object; update the logic to guard against empty body and non-object JSON by
checking that text is non-empty, JSON.parse succeeds to produce an object, and
that typeof data === "object" && data !== null before reading data.status; when
the body is empty treat it as a specific error or return a sensible default, and
ensure rejects/logs use clear messages (e.g., "empty response" or parsed error)
instead of misclassifying as a network_error.

Comment on lines +41 to 51
// 네트워크 에러나 기타 에러도 조용히 처리
console.log("예수금 추가 실패 (조용히 처리):", err.message || err);

// JSON 파싱 에러나 네트워크 에러의 경우
if (err.message && err.message.includes('JSON')) {
return Promise.reject("parsing_error");
}

// 기타 에러의 경우
return Promise.reject("network_error");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

에러 분류가 err.message 문자열 검색에 의존 — 오분류 위험
includes('JSON')는 신뢰하기 어렵습니다. 표준 에러 특성(name/instanceof) 기반으로 분기하세요. 타임아웃도 분리 추천.

패치:

-    // JSON 파싱 에러나 네트워크 에러의 경우
-    if (err.message && err.message.includes('JSON')) {
-      return Promise.reject("parsing_error");
-    }
-    
-    // 기타 에러의 경우
-    return Promise.reject("network_error");
+    // 분류 가능한 에러 코드로 매핑
+    if (err?.name === "AbortError") {
+      return Promise.reject("timeout_error");
+    }
+    if (err instanceof SyntaxError) {
+      return Promise.reject("parsing_error");
+    }
+    return Promise.reject("network_error");

추가로, fetchWithAuthAbortController 기반 timeoutMs 옵션을 도입하면 타임아웃 분류가 정확해집니다. 원하시면 보일러플레이트 제공 가능합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 네트워크 에러나 기타 에러도 조용히 처리
console.log("예수금 추가 실패 (조용히 처리):", err.message || err);
// JSON 파싱 에러나 네트워크 에러의 경우
if (err.message && err.message.includes('JSON')) {
return Promise.reject("parsing_error");
}
// 기타 에러의 경우
return Promise.reject("network_error");
}
// 네트워크 에러나 기타 에러도 조용히 처리
console.log("예수금 추가 실패 (조용히 처리):", err.message || err);
// 분류 가능한 에러 코드로 매핑
if (err?.name === "AbortError") {
return Promise.reject("timeout_error");
}
if (err instanceof SyntaxError) {
return Promise.reject("parsing_error");
}
return Promise.reject("network_error");
}
🤖 Prompt for AI Agents
In src/utils/point.js around lines 41 to 51, the error classification currently
relies on err.message.includes('JSON') which is fragile; change branching to use
standard error characteristics (e.g., check instanceof SyntaxError or err.name
=== 'SyntaxError' for JSON parse errors) and detect network/timeout errors
explicitly (e.g., check for DOMException with name 'AbortError' or a custom
TimeoutError). Also add a timeoutMs option to fetchWithAuth that uses an
AbortController to cancel requests on timeout so you can reliably classify
timeouts versus other network errors; update the catch logic to first test for
timeout/abort, then JSON parse errors via instanceof/name, and finally return
the generic network_error for remaining cases.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/services/PushNotificationService.ios.js (1)

136-139: Expo Push 토큰 전체값 로그 제거/마스킹 필요

토큰은 자격증명입니다. 전체값을 로그로 남기면 유출 위험이 큽니다. DEV에서만 마스킹해 출력하세요.

-    console.log("📢 [Push] ExpoPushToken:", expoPushToken);
+    if (__DEV__) {
+      console.log("📢 [Push] ExpoPushToken(obfuscated):", maskToken(expoPushToken));
+    }

헬퍼 추가(내부 유틸 섹션 상단 등 적절한 위치):

function maskToken(t) {
  if (typeof t !== "string") return "<unknown>";
  return `${t.slice(0, 12)}${t.slice(-6)}`;
}
App.js (1)

10-15: Android 번들 시 export 불일치 가능성 — iOS 분기 내 동적 require로 안전화

Android 변형 모듈이 registerExpoPushToken를 export하지 않으면 정적 import가 번들 단계에서 깨질 수 있습니다. iOS 분기 안에서 require로 로컬 로드하세요.

-// Push 서비스
-import {
-  setupNotificationListeners,
-  registerExpoPushToken,
-} from "./src/services/PushNotificationService";
+// iOS 전용: 분기 내부에서 require로 로드
+let setupNotificationListeners, registerExpoPushToken;
       // ===== 리스너 등록 =====
-      console.log("[Push] setupNotificationListeners() 호출");
-      const serviceCleanup = setupNotificationListeners?.();
+      console.log("[Push] setupNotificationListeners() 호출");
+      ({ setupNotificationListeners, registerExpoPushToken } =
+        require("./src/services/PushNotificationService"));
+      const serviceCleanup = setupNotificationListeners?.();

Also applies to: 76-83

🧹 Nitpick comments (4)
src/services/PushNotificationService.ios.js (3)

41-74: 네트워크 타임아웃 없음 → 무한 대기 리스크

fetch에 타임아웃/취소 없으면 요청이 매달릴 수 있습니다. AbortController로 10s 타임아웃을 권장합니다.

 async function uploadTokenToServer(token) {
   try {
     const deviceId = await getOrCreateDeviceId();
     const accessToken = await AsyncStorage.getItem("accessToken"); // 🔑 로그인 토큰

     const body = { token, deviceId, platform: "ios" };

-    const res = await fetch(`${API_BASE_URL}api/push-tokens`, {
-      method: "POST",
-      headers: {
-        "Content-Type": "application/json",
-        ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
-      },
-      body: JSON.stringify(body),
-    });
+    const ac = new AbortController();
+    const timer = setTimeout(() => ac.abort(), 10000);
+    let res;
+    try {
+      res = await fetch(`${API_BASE_URL}api/push-tokens`, {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
+        },
+        body: JSON.stringify(body),
+        signal: ac.signal,
+      });
+    } finally {
+      clearTimeout(timer);
+    }
 async function deleteTokenFromServer(token) {
   try {
     const accessToken = await AsyncStorage.getItem("accessToken");
-    const res = await fetch(`${API_BASE_URL}api/push-tokens`, {
-      method: "DELETE",
-      headers: {
-        "Content-Type": "application/json",
-        ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
-      },
-      body: JSON.stringify({ token }),
-    });
+    const ac = new AbortController();
+    const timer = setTimeout(() => ac.abort(), 10000);
+    let res;
+    try {
+      res = await fetch(`${API_BASE_URL}api/push-tokens`, {
+        method: "DELETE",
+        headers: {
+          "Content-Type": "application/json",
+          ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
+        },
+        body: JSON.stringify({ token }),
+        signal: ac.signal,
+      });
+    } finally {
+      clearTimeout(timer);
+    }

검증: 현재 RN 버전에서 AbortController 지원 여부 확인 필요합니다.

Also applies to: 76-99


25-29: 빈 catch로 장애 탐지 어려움 — DEV에서만 경고 출력 권장

저장/로딩 실패 시 최소한 DEV 로그를 남겨 원인 추적 가능하게 하세요.

   try {
     await AsyncStorage.setItem("pushToken", token);
-  } catch {}
+  } catch (e) {
+    if (__DEV__) console.warn("[Push][WARN] saveLocalToken failed:", e?.message || String(e));
+  }
   try {
     return (await AsyncStorage.getItem("pushToken")) || null;
-  } catch {
-    return null;
-  }
+  } catch (e) {
+    if (__DEV__) console.warn("[Push][WARN] loadLocalToken failed:", e?.message || String(e));
+    return null;
+  }

Also applies to: 31-36


13-22: AsyncStorage 키 네임스페이스 권장

일반 키("deviceId","pushToken") 충돌 방지를 위해 접두어를 두세요(예: "ddk:push:token", "ddk:device:id").

-  const KEY = "deviceId";
+  const KEY = "ddk:device:id";
-    await AsyncStorage.setItem("pushToken", token);
+    await AsyncStorage.setItem("ddk:push:token", token);
-    return (await AsyncStorage.getItem("pushToken")) || null;
+    return (await AsyncStorage.getItem("ddk:push:token")) || null;

Also applies to: 25-36

App.js (1)

86-92: 알림 payload 전체 JSON 로그는 PII 노출 위험 — DEV 가드

푸시 payload를 그대로 출력하면 민감 정보가 남습니다. DEV 한정 상세 로그로 제한하세요.

-                console.log(
-                  "[Push][recv] (fg) notification:",
-                  JSON.stringify(n, null, 2)
-                );
+                if (__DEV__) {
+                  console.log("[Push][recv] (fg) notification:", JSON.stringify(n, null, 2));
+                } else {
+                  console.log("[Push][recv] (fg) notification");
+                }
-                console.log("[Push][tap] response:", JSON.stringify(r, null, 2));
+                if (__DEV__) {
+                  console.log("[Push][tap] response:", JSON.stringify(r, null, 2));
+                } else {
+                  console.log("[Push][tap] notification tapped");
+                }

Also applies to: 99-103

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c14c636 and 3205923.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (5)
  • .expo/devices.json (1 hunks)
  • App.js (1 hunks)
  • app.json (3 hunks)
  • package.json (3 hunks)
  • src/services/PushNotificationService.ios.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • app.json
  • package.json
  • .expo/devices.json
🧰 Additional context used
🧬 Code graph analysis (2)
App.js (2)
src/services/PushNotificationService.ios.js (7)
  • Notifications (116-118)
  • Notifications (154-154)
  • existing (113-113)
  • setupNotificationListeners (176-188)
  • res (53-60)
  • res (79-86)
  • registerExpoPushToken (123-144)
src/services/PushNotificationService.js (3)
  • Notifications (33-33)
  • setupNotificationListeners (193-220)
  • setupNotificationListeners (193-220)
src/services/PushNotificationService.ios.js (2)
src/utils/token.js (3)
  • accessToken (7-7)
  • accessToken (129-129)
  • accessToken (171-171)
src/utils/apiConfig.js (2)
  • API_BASE_URL (1-2)
  • API_BASE_URL (1-2)
🔇 Additional comments (2)
src/services/PushNotificationService.ios.js (1)

123-133: skipped에도 success: true 반환 — 호출부 의미 혼선 가능

현재 skipped 시에도 success: true라 App.js에서 “실패”로 로그될 수 있습니다. success 의미(“토큰 발급 성공” vs “프로세스 자체 성공”)를 정리하고, 필요 시 App.js에서 skipped를 분기 처리해주세요.

App.js (1)

16-16: 환영 알림 기본값 true — 프로덕션 의도 확인 필요

SHOW_WELCOME_ON_LAUNCH가 기본 true입니다. 프로덕션에서도 노출 의도라면 OK, 아니라면 빌드/원격 플래그로 제어하세요.

Comment on lines +111 to +117
console.log("[Push] registerExpoPushToken() 호출");
const res = await registerExpoPushToken();
console.log("[Push] registerExpoPushToken() result:", res);

if (res?.success && res?.expoPushToken) {
console.log("✅ [Push] ExpoPushToken:", res.expoPushToken);
} else {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

민감정보(토큰) 포함 결과 로그 — 마스킹/축약 필요

결과 전체 로그에 토큰이 포함됩니다. DEV에서만 마스킹해 출력하거나 핵심 필드만 남기세요.

-        const res = await registerExpoPushToken();
-        console.log("[Push] registerExpoPushToken() result:", res);
+        const res = await registerExpoPushToken();
+        const masked =
+          res?.expoPushToken ? { ...res, expoPushToken: `***${res.expoPushToken.slice(-6)}` } : res;
+        console.log(
+          "[Push] registerExpoPushToken() result:",
+          __DEV__ ? masked : { success: res?.success, uploaded: res?.uploaded, skipped: res?.skipped }
+        );
-        if (res?.success && res?.expoPushToken) {
-          console.log("✅ [Push] ExpoPushToken:", res.expoPushToken);
+        if (res?.success && res?.expoPushToken) {
+          if (__DEV__) console.log("✅ [Push] ExpoPushToken:", `***${res.expoPushToken.slice(-6)}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log("[Push] registerExpoPushToken() 호출");
const res = await registerExpoPushToken();
console.log("[Push] registerExpoPushToken() result:", res);
if (res?.success && res?.expoPushToken) {
console.log("✅ [Push] ExpoPushToken:", res.expoPushToken);
} else {
console.log("[Push] registerExpoPushToken() 호출");
const res = await registerExpoPushToken();
const masked =
res?.expoPushToken ? { ...res, expoPushToken: `***${res.expoPushToken.slice(-6)}` } : res;
console.log(
"[Push] registerExpoPushToken() result:",
__DEV__ ? masked : { success: res?.success, uploaded: res?.uploaded, skipped: res?.skipped }
);
if (res?.success && res?.expoPushToken) {
if (__DEV__) console.log("✅ [Push] ExpoPushToken:", `***${res.expoPushToken.slice(-6)}`);
} else {
🤖 Prompt for AI Agents
In App.js around lines 111 to 117, the code logs the full expoPushToken which
leaks sensitive token data; change the logging to avoid printing the entire
token by either (a) only logging the presence/status (e.g., "expoPushToken
received") or (b) logging a masked/abbreviated token (e.g., first 4 and last 4
chars with ellipsis) and wrap any unmasked logging behind a development-only
check (NODE_ENV === 'development') so production never emits the raw token.

Comment on lines +67 to 69
console.log("[Push] 등록 성공:", body);
await saveLocalToken(token);
return true;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

성공 로그에도 토큰이 그대로 노출됨

등록/해제 성공 로그에 토큰 원문이 포함됩니다. 마스킹 후 출력하거나 프로덕션에서는 숨기세요.

-    console.log("[Push] 등록 성공:", body);
+    if (__DEV__) {
+      console.log("[Push] 등록 성공:", { ...body, token: maskToken(token) });
+    } else {
+      console.log("[Push] 등록 성공");
+    }

-    console.log("[Push] 해제 성공:", token);
+    console.log("[Push] 해제 성공:", __DEV__ ? maskToken(token) : "<hidden>");

Also applies to: 93-94

🤖 Prompt for AI Agents
In src/services/PushNotificationService.ios.js around lines 67-69 (and similarly
at 93-94), the success logs print the raw token; change the logging to avoid
exposing the full token by masking it before logging (e.g., replace all but the
last 4 chars with asterisks or show only the token length), or remove the token
from logs in production via an environment check; implement a small helper
maskPushToken(token) that returns a masked string and use it in the success
console.log calls so no raw token is printed.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/screens/Auth/SignUp2Screen.js (1)

343-352: showSoftInputOnFocus=false로 비밀번호 입력 시 키보드가 뜨지 않습니다

Android에서 showSoftInputOnFocus={false}를 주면 포커스가 가도 IME가 열리지 않습니다. 현재 코드에는 키보드를 수동으로 띄우는 로직이 없으므로 사용자가 비밀번호를 입력할 수 없어 치명적입니다. 해당 속성을 제거해주세요.

-              showSoftInputOnFocus={false}
🧹 Nitpick comments (2)
src/screens/Guide/GuideLevel1.js (1)

31-75: useFocusEffect 의존성 정리 필요

Line 64에서 useFocusEffect에 빈 deps를 넘기고 있는데, fetchProgress는 렌더마다 새로 정의됩니다. 지금은 우연히 동작하지만 eslint-plugin-react-hooks 경고가 발생하고, 앞으로 fetchProgress 안에서 다른 상태/props를 읽게 되면 최초 렌더 시점의 값으로 고정될 위험이 있습니다. fetchProgressReact.useCallback으로 메모이즈하고, useFocusEffecthandleRetry가 동일한 함수를 바라보도록 정리해 주세요.

-  const fetchProgress = async () => {
+  const fetchProgress = React.useCallback(async () => {
     ...
-  };
+  }, [navigation]);
 
   useFocusEffect(
-    React.useCallback(() => {
-      fetchProgress();
-    }, [])
+    React.useCallback(() => {
+      fetchProgress();
+    }, [fetchProgress])
   );
 
-  const handleRetry = () => fetchProgress();
+  const handleRetry = React.useCallback(() => {
+    fetchProgress();
+  }, [fetchProgress]);
src/screens/Guide/GuideLevel3.js (1)

33-80: focus 재진입 시 fetchProgress 안정화 필요

Line 71에서도 useFocusEffect가 빈 deps를 사용하고 있고, fetchProgress는 매 렌더마다 새 함수로 재생성됩니다. 현재 구현은 작동하더라도 훅 린트 경고와 함께, 추후 fetchProgress가 다른 상태를 참조하게 되면 초기 렌더 값에 고정될 수 있습니다. fetchProgressReact.useCallback([navigation])으로 감싸고, useFocusEffecthandleRetry에서 동일한 함수를 재사용하도록 조정하는 것이 좋겠습니다.

-  const fetchProgress = async () => {
+  const fetchProgress = React.useCallback(async () => {
     ...
-  };
+  }, [navigation]);
 
-  useFocusEffect(
-    React.useCallback(() => {
-      fetchProgress();
-    }, [])
-  );
+  useFocusEffect(
+    React.useCallback(() => {
+      fetchProgress();
+    }, [fetchProgress])
+  );
 
-  const handleRetry = () => {
-    fetchProgress();
-  };
+  const handleRetry = React.useCallback(() => {
+    fetchProgress();
+  }, [fetchProgress]);
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3205923 and 19ca701.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • package.json (2 hunks)
  • src/screens/Auth/SignUp2Screen.js (7 hunks)
  • src/screens/Guide/GuideLevel1.js (6 hunks)
  • src/screens/Guide/GuideLevel2.js (6 hunks)
  • src/screens/Guide/GuideLevel3.js (7 hunks)
  • src/screens/Main/MainScreen.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/screens/Guide/GuideLevel2.js
🧰 Additional context used
🧬 Code graph analysis (3)
src/screens/Auth/SignUp2Screen.js (2)
src/screens/Auth/FindPasswordScreen.js (4)
  • scrollRef (42-42)
  • keyboardVisible (45-45)
  • keyboardHeight (46-46)
  • bottomSpacer (63-66)
src/screens/Chatbot/ChatbotScreen.js (2)
  • scrollRef (44-44)
  • keyboardHeight (42-42)
src/screens/Guide/GuideLevel3.js (3)
src/screens/Guide/StudyScreen.js (7)
  • screenWidth (21-21)
  • navigation (25-25)
  • insets (26-26)
  • loading (37-37)
  • error (39-39)
  • handleRetry (144-146)
  • styles (357-622)
src/screens/Guide/GuideLevel1.js (7)
  • navigation (24-24)
  • insets (25-25)
  • loading (27-27)
  • error (29-29)
  • fetchProgress (31-62)
  • handleRetry (70-70)
  • styles (150-239)
src/screens/Guide/GuideLevel2.js (8)
  • navigation (24-24)
  • insets (25-25)
  • loading (27-27)
  • error (29-29)
  • fetchProgress (31-62)
  • handleRetry (70-70)
  • handleChapterPress (72-77)
  • styles (158-247)
src/screens/Guide/GuideLevel1.js (2)
src/screens/Guide/GuideLevel2.js (10)
  • insets (25-25)
  • loading (27-27)
  • contentProgress (28-28)
  • error (29-29)
  • fetchProgress (31-62)
  • navigation (24-24)
  • handleRetry (70-70)
  • styles (158-247)
  • entries (99-101)
  • firstIncomplete (103-103)
src/screens/Guide/GuideLevel3.js (10)
  • insets (28-28)
  • loading (29-29)
  • contentProgress (30-30)
  • error (31-31)
  • fetchProgress (33-69)
  • navigation (27-27)
  • handleRetry (77-79)
  • styles (179-279)
  • entries (108-110)
  • firstIncomplete (112-112)
🔇 Additional comments (3)
package.json (1)

18-18: expo-constants 버전을 SDK 53 호환 범위로 고정하세요

"expo": "~53.0.0"과 함께 expo-constants*로 두면 이후 설치 시 최신 버전(예: 18.x)이 끌려와 빌드가 깨집니다. Expo SDK 53 번들 버전인 ~17.1.7로 고정해야 합니다. (이전에 동일 지적이 있었으니 이번에 꼭 반영 부탁드립니다.)

-    "expo-constants": "*",
+    "expo-constants": "~17.1.7",
src/screens/Main/MainScreen.js (1)

549-555: 상단 여백 조정이 새 Safe Area 대응과 잘 맞습니다

scrollContentpaddingTop을 줄이고 searchContainermarginTop을 늘린 덕분에 전체 상단 오프셋이 약간만 줄어들어, 상단 인셋을 네비게이터에서 처리하는 최근 변경과 자연스럽게 맞물립니다. 별다른 부작용 없이 배치를 더 안정적으로 정렬할 수 있을 것 같습니다.

src/screens/Auth/SignUp2Screen.js (1)

320-324: ScrollView에 ref가 연결되지 않아 스크롤이 동작하지 않습니다

scrollRef를 선언했지만 ScrollView의 ref로 전달하지 않아 scrollRef.current는 항상 null이고, 추가 주소 필드 onFocus에서 호출하는 scrollRef.current?.scrollToEnd가 전혀 실행되지 않습니다. 이전 리뷰에서도 동일한 문제가 지적되었으니 이번에 반드시 반영해주세요.

         <ScrollView
+          ref={scrollRef}
           style={styles.scroll}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant