diff --git a/.expo/devices.json b/.expo/devices.json index 5efff6c..89b430c 100644 --- a/.expo/devices.json +++ b/.expo/devices.json @@ -1,3 +1,28 @@ { - "devices": [] + "devices": [ + { + "installationId": "F0B2F989-D042-4D9B-BE7C-8D09B00BD4C5", + "lastUsed": 1758012043849 + }, + { + "installationId": "8E6F5961-3A90-478E-B947-09F0A98060DB", + "lastUsed": 1757575505218 + }, + { + "installationId": "38AB03E5-3FFC-4068-95D6-6819BAC877BA", + "lastUsed": 1757568886349 + }, + { + "installationId": "FE1F6210-9324-47BF-971B-25ED7730754F", + "lastUsed": 1757554107669 + }, + { + "installationId": "2A00458E-CB06-4F84-954E-01AB454A72C9", + "lastUsed": 1757484949022 + }, + { + "installationId": "453B7860-472B-43DC-987E-3F54A4AA6955", + "lastUsed": 1757381271381 + } + ] } diff --git a/.expo/web/cache/production/images/iconsuniversal-icon/iconsuniversal-icon-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-cover-#ffffff/App-Icon-1024x1024@1x.png b/.expo/web/cache/production/images/iconsuniversal-icon/iconsuniversal-icon-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-cover-#ffffff/App-Icon-1024x1024@1x.png new file mode 100644 index 0000000..faa06ef Binary files /dev/null and b/.expo/web/cache/production/images/iconsuniversal-icon/iconsuniversal-icon-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-cover-#ffffff/App-Icon-1024x1024@1x.png differ diff --git a/.expo/web/cache/production/images/splash-ios/splash-ios-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-contain/icon_undefined.png b/.expo/web/cache/production/images/splash-ios/splash-ios-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-contain/icon_undefined.png new file mode 100644 index 0000000..93365c6 Binary files /dev/null and b/.expo/web/cache/production/images/splash-ios/splash-ios-b4d49e7392f44b0e1b4fd25a5b828b2bf3fd57e856a1c968620b71d61659e519-contain/icon_undefined.png differ diff --git a/App.js b/App.js index d2e0ac3..42dd82c 100644 --- a/App.js +++ b/App.js @@ -1,38 +1,181 @@ -import 'react-native-gesture-handler'; -import React, { useEffect, useRef } from 'react'; -import { NavigationContainer } from '@react-navigation/native'; -import StackNavigator from './src/navigation/StackNavigator'; -import { setupNotificationListeners } from './src/services/PushNotificationService'; +// App.js +import "react-native-gesture-handler"; +import React, { useEffect, useRef } from "react"; +import { Platform } from "react-native"; +import { NavigationContainer } from "@react-navigation/native"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import StackNavigator from "./src/navigation/StackNavigator"; + +// Push 서비스 +import { + setupNotificationListeners, + registerExpoPushToken, +} from "./src/services/PushNotificationService"; + +const SHOW_WELCOME_ON_LAUNCH = true; + +// ============================================ +// 전역 Notification Handler +// ============================================ +console.log("[Push] setNotificationHandler: init"); +Notifications.setNotificationHandler({ + handleNotification: async () => { + console.log("[Push] handleNotification called (foreground display enabled)"); + return { + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: true, + }; + }, +}); export default function App() { - const navigationRef = useRef(); - const cleanupRef = useRef(); + const navigationRef = useRef(null); + const cleanupRef = useRef(null); + const localListeners = useRef({ received: null, response: null }); + const shownWelcomeRef = useRef(false); useEffect(() => { + let timer; + + const initializeNotifications = async () => { + console.log("[Push] initializeNotifications: start"); + console.log("[Push] Platform:", Platform.OS); - const initializeNotifications = () => { - if (navigationRef.current) { - console.log('expo 푸시알림 리스너 초기화'); - const cleanup = setupNotificationListeners(navigationRef.current); - cleanupRef.current = cleanup; - - return cleanup; + if (Platform.OS !== "ios") { + console.log("[Push] (skipped) iOS 전용 로직. 현재:", Platform.OS); + return; } + + if (!Device.isDevice) { + console.log("[Push][WARN] 시뮬레이터는 원격 푸시 수신 불가. 실기기 필요."); + return; + } + + // ===== 권한 확인 ===== + try { + const existing = await Notifications.getPermissionsAsync(); + console.log("[Push] permissions(existing):", existing?.status, existing); + if (existing.status !== "granted") { + const { status } = await Notifications.requestPermissionsAsync({ + ios: { allowAlert: true, allowSound: true, allowBadge: true }, + }); + console.log("[Push] permission after request:", status); + if (status !== "granted") { + console.warn("[Push] permission denied; skip token"); + return; + } + } + } catch (e) { + console.log("[Push][ERR] getPermissionsAsync failed:", e?.message || e); + return; + } + + // ===== 리스너 등록 ===== + console.log("[Push] setupNotificationListeners() 호출"); + const serviceCleanup = setupNotificationListeners?.(); + cleanupRef.current = serviceCleanup; + + 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) { + console.log("[Push][ERR] add listeners failed:", e?.message || e); + } + + // ===== 토큰 발급 + 서버 업서트 ===== + try { + 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.warn("❌ [Push] Expo 토큰 발급 실패:", res?.error); + } + } catch (e) { + console.warn("[Push][ERR] registerExpoPushToken error:", e?.message || e); + } + + // ===== 환영 배너 ===== + if (SHOW_WELCOME_ON_LAUNCH && !shownWelcomeRef.current) { + shownWelcomeRef.current = true; + try { + const now = new Date(); + const time = new Intl.DateTimeFormat("ko-KR", { + hour: "2-digit", + minute: "2-digit", + }).format(now); + await Notifications.scheduleNotificationAsync({ + content: { + title: "👋 환영합니다!", + body: `${time} 접속했습니다.`, + data: { _meta: "welcome" }, + sound: "default", + }, + trigger: null, + }); + } catch (e) { + console.log("[Push][ERR] schedule welcome failed:", e?.message || e); + } + } + + console.log("[Push] initializeNotifications: done"); }; - const timer = setTimeout(initializeNotifications, 1000); + timer = setTimeout(initializeNotifications, 500); return () => { clearTimeout(timer); - if (cleanupRef.current) { - cleanupRef.current(); - } + try { + cleanupRef.current?.(); + } catch {} + try { + if (localListeners.current.received) { + Notifications.removeNotificationSubscription(localListeners.current.received); + localListeners.current.received = null; + } + if (localListeners.current.response) { + Notifications.removeNotificationSubscription(localListeners.current.response); + localListeners.current.response = null; + } + } catch {} + console.log("[Push] cleanup completed"); }; }, []); return ( - + { + navigationRef.current = r; + if (r) console.log("[Nav] navigationRef ready"); + }} + > ); -} \ No newline at end of file +} diff --git a/app.config.js b/app.config.js deleted file mode 100644 index bb00842..0000000 --- a/app.config.js +++ /dev/null @@ -1,15 +0,0 @@ -export default ({ config }) => { - const baseConfig = { - ...config, - extra: { - eas: { - projectId: process.env.EAS_PROJECT_ID || "d831fa11-69a9-40eb-a916-ae0d22291e92" - } - }, - owner: process.env.EAS_OWNER || "r8ol7z" - }; - - return baseConfig; - }; - - // 일단 계정 정보 기본값 -> 승연 \ No newline at end of file diff --git a/app.json b/app.json index a30f655..c244bd0 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,8 @@ { "expo": { - "name": "Doodook", + "name": "두둑", "slug": "doodook", + "owner": "yehyeon", "version": "1.0.0", "orientation": "portrait", "icon": "./src/assets/images/logo-d-app-icon-1500.png", @@ -18,7 +19,7 @@ ], "ios": { "bundleIdentifier": "com.lipeuoo.doodook", - "buildNumber": "7", + "buildNumber": "22", "supportsTablet": true, "infoPlist": { "UIBackgroundModes": [ @@ -46,7 +47,11 @@ "color": "#F074BA" } ] - ] - + ], + "extra": { + "eas": { + "projectId": "57b0a621-af5d-4605-b6e0-cc46a6c474ec" + } + } } } diff --git a/package-lock.json b/package-lock.json index 4d78ed6..da5f658 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,11 @@ "@react-navigation/stack": "^7.4.7", "expo": "~53.0.0", "expo-asset": "~11.1.7", - "expo-constants": "^17.1.7", + "expo-constants": "*", "expo-device": "~7.1.4", "expo-font": "*", "expo-linking": "*", - "expo-notifications": "^0.31.4", + "expo-notifications": "~0.31.4", "expo-sharing": "~13.1.5", "expo-splash-screen": "*", "expo-status-bar": "~2.2.3", @@ -3286,36 +3286,13 @@ "integrity": "sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ==", "license": "MIT" }, - "node_modules/@react-native/virtualized-lists": { - "version": "0.79.5", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz", - "integrity": "sha512-EUPM2rfGNO4cbI3olAbhPkIt3q7MapwCwAJBzUfWlZ/pu0PRNOnMQ1IvaXTf3TpeozXV52K1OdprLEI/kI5eUA==", - "license": "MIT", - "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": "^19.0.0", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.6.tgz", - "integrity": "sha512-f4khxwcL70O5aKfZFbxyBo5RnzPFnBNSXmrrT7q9CRmvN4mHov9KFKGQ3H4xD5sLonsTBtyjvyvPfyEC4G7f+g==", + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.7.tgz", + "integrity": "sha512-SQ4KuYV9yr3SV/thefpLWhAD0CU2CrBMG1l0w/QKl3GYuGWdN5OQmdQdmaPZGtsjjVOb+N9Qo7Tf6210P4TlpA==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.6.3", + "@react-navigation/elements": "^2.6.4", "color": "^4.2.3" }, "peerDependencies": { @@ -3345,9 +3322,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.6.3.tgz", - "integrity": "sha512-hcPXssZg5bFD5oKX7FP0D9ZXinRgPUHkUJbTegpenSEUJcPooH1qzWJkEP22GrtO+OPDLYrCVZxEX8FcMrn4pA==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.6.4.tgz", + "integrity": "sha512-O3X9vWXOEhAO56zkQS7KaDzL8BvjlwZ0LGSteKpt1/k6w6HONG+2Wkblrb057iKmehTkEkQMzMLkXiuLmN5x9Q==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -3394,12 +3371,12 @@ } }, "node_modules/@react-navigation/stack": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.4.7.tgz", - "integrity": "sha512-1VDxuou+iZXEK7o+ZtCIU3b+upAwLFolptEKX+GldUy78GR66hltPxmu8bJeb2ZcgUSowBTHw03kNY1gyOdW3g==", + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.4.8.tgz", + "integrity": "sha512-zZsX52Nw1gsq33Hx4aNgGV2RmDJgVJM71udomCi3OdlntqXDguav3J2t5oe/Acf/9uU8JiJE9W8JGtoRZ6nXIg==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.6.3", + "@react-navigation/elements": "^2.6.4", "color": "^4.2.3" }, "peerDependencies": { @@ -4568,9 +4545,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", - "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "funding": [ { "type": "opencollective", @@ -4587,8 +4564,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001735", - "electron-to-chromium": "^1.5.204", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -4756,9 +4733,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001736", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001736.tgz", - "integrity": "sha512-ImpN5gLEY8gWeqfLUyEF4b7mYWcYoR2Si1VhnrbM4JizRFmfGaAQ12PhNykq6nvI4XvKLrsp8Xde74D5phJOSw==", + "version": "1.0.30001739", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", + "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", "funding": [ { "type": "opencollective", @@ -5277,9 +5254,9 @@ "license": "CC0-1.0" }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "devOptional": true, "license": "MIT" }, @@ -5555,9 +5532,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.208", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.208.tgz", - "integrity": "sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg==", + "version": "1.5.211", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", + "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9736,6 +9713,29 @@ "react-native": "*" } }, + "node_modules/react-native/node_modules/@react-native/virtualized-lists": { + "version": "0.79.5", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz", + "integrity": "sha512-EUPM2rfGNO4cbI3olAbhPkIt3q7MapwCwAJBzUfWlZ/pu0PRNOnMQ1IvaXTf3TpeozXV52K1OdprLEI/kI5eUA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^19.0.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-native/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/package.json b/package.json index ade42b9..9d9d70a 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "main": "node_modules/expo/AppEntry.js", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web" }, "dependencies": { @@ -15,11 +15,11 @@ "@react-navigation/stack": "^7.4.7", "expo": "~53.0.0", "expo-asset": "~11.1.7", - "expo-constants": "^17.1.7", + "expo-constants": "*", "expo-device": "~7.1.4", "expo-font": "*", "expo-linking": "*", - "expo-notifications": "^0.31.4", + "expo-notifications": "~0.31.4", "expo-sharing": "~13.1.5", "expo-splash-screen": "*", "expo-status-bar": "~2.2.3", @@ -42,9 +42,5 @@ "@babel/core": "^7.28.3", "@react-native-community/cli": "^20.0.1", "react-native-svg-transformer": "^1.5.1" - }, - "overrides": { - "react": "$react", - "react-dom": "$react-dom" } } diff --git a/src/assets/fonts/Pretendard-Bold.otf b/src/assets/fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/src/assets/fonts/Pretendard-Bold.otf differ diff --git a/src/assets/fonts/Pretendard-Medium.otf b/src/assets/fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/src/assets/fonts/Pretendard-Medium.otf differ diff --git a/src/assets/fonts/Pretendard-Regular.otf b/src/assets/fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/src/assets/fonts/Pretendard-Regular.otf differ diff --git a/src/assets/fonts/Pretendard-SemiBold.otf b/src/assets/fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/src/assets/fonts/Pretendard-SemiBold.otf differ diff --git a/src/assets/fonts/Pretendard-Thin.otf b/src/assets/fonts/Pretendard-Thin.otf new file mode 100644 index 0000000..77e792d Binary files /dev/null and b/src/assets/fonts/Pretendard-Thin.otf differ diff --git a/src/assets/rainbow.png b/src/assets/rainbow.png index 6136229..e4689cc 100644 Binary files a/src/assets/rainbow.png and b/src/assets/rainbow.png differ diff --git a/src/navigation/MainTab.js b/src/navigation/MainTab.js index 8c7e4d0..60b49bd 100644 --- a/src/navigation/MainTab.js +++ b/src/navigation/MainTab.js @@ -1,5 +1,7 @@ import React from 'react'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Platform } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import MainScreen from '../screens/Main/MainScreen'; import GuideScreen from '../screens/Guide/GuideScreen'; import ChatbotScreen from '../screens/Chatbot/ChatbotScreen'; @@ -14,10 +16,23 @@ import MyPageIcon from '../assets/icons/mypage.svg'; import MyPageSelectedIcon from '../assets/icons/mypage-selected.svg'; import GuideIcon from '../assets/icons/guide.svg'; import GuideSelectedIcon from '../assets/icons/guide-selected.svg'; + const Tab = createBottomTabNavigator(); const MainTab = () => { 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; + }; + return ( ({ @@ -25,16 +40,19 @@ const MainTab = () => { tabBarStyle: { backgroundColor: '#003340', borderTopColor: 'transparent', - height: 65, - paddingBottom: 15, + height: getTabBarHeight(), + paddingBottom: Platform.OS === 'android' ? + Math.max(insets.bottom, 15) : + insets.bottom + 15, + paddingTop: 16, position: 'absolute', bottom: 0, left: 0, right: 0, elevation: 0, - zIndex: 999, + shadowOpacity: 0, // iOS 그림자 제거 }, - tabBarIcon: ({ focused, size }) => { + tabBarIcon: ({ focused }) => { console.log(`Tab pressed: ${route.name}, focused: ${focused}`); let Icon; if (route.name === 'Home') { @@ -46,7 +64,7 @@ const MainTab = () => { } else { Icon = focused ? MyPageSelectedIcon : MyPageIcon; } - return ; + return ; }, tabBarShowLabel: false, })}> diff --git a/src/screens/Auth/FindPasswordScreen.js b/src/screens/Auth/FindPasswordScreen.js index bc79ecf..dcbcb6a 100644 --- a/src/screens/Auth/FindPasswordScreen.js +++ b/src/screens/Auth/FindPasswordScreen.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useMemo, useRef, useState, useEffect } from "react"; import { View, Text, @@ -6,191 +6,536 @@ import { TouchableOpacity, Alert, StyleSheet, + ScrollView, + Dimensions, + ActivityIndicator, + KeyboardAvoidingView, + Platform, + SafeAreaView, + Keyboard, } from "react-native"; import { API_BASE_URL } from "../../utils/apiConfig"; +import EyeOpen from "../../components/EyeOpen"; +import EyeClosed from "../../components/EyeClosed"; + +const { height, width } = Dimensions.get("window"); const FindPasswordScreen = ({ navigation }) => { + const [step, setStep] = useState(1); // 1: 이메일 입력, 2: 인증번호 + 새 비밀번호 + + // 이메일 입력 단계 const [email, setEmail] = useState(""); - const [loading, setLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 인증 단계 + const [code, setCode] = useState(["", "", "", "", "", ""]); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [seeNewPassword, setSeeNewPassword] = useState(true); + const [seeConfirmPassword, setSeeConfirmPassword] = useState(true); + const [isVerifying, setIsVerifying] = useState(false); + + // refs + const codeInputs = useRef([]); + const refNewPw = useRef(null); + const refConfirmPw = useRef(null); + const scrollRef = useRef(null); + + // 키보드 처리 + const [keyboardVisible, setKeyboardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + const showSub = Keyboard.addListener("keyboardDidShow", (e) => { + setKeyboardVisible(true); + setKeyboardHeight(e?.endCoordinates?.height ?? 0); + }); + const hideSub = Keyboard.addListener("keyboardDidHide", () => { + setKeyboardVisible(false); + setKeyboardHeight(0); + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + const bottomSpacer = useMemo(() => { + if (!keyboardVisible) return 120; + return Math.max(220, keyboardHeight + 140); + }, [keyboardVisible, keyboardHeight]); + + // validators + const validateEmail = (e) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((e || "").trim().toLowerCase()); + + const passwordValid = (p) => { + const s = p || ""; + if (s.length < 8 || s.length > 32) return false; + const kinds = + (/[A-Za-z]/.test(s) ? 1 : 0) + (/\d/.test(s) ? 1 : 0) + (/[^\w\s]/.test(s) ? 1 : 0); + return kinds >= 2; + }; + + // 비밀번호 강도(0~3) + const passwordStrength = useMemo(() => { + if (!newPassword) return 0; + const lenScore = newPassword.length >= 12 ? 1 : 0; + const kinds = + (/[A-Z]/.test(newPassword) ? 1 : 0) + + (/[a-z]/.test(newPassword) ? 1 : 0) + + (/\d/.test(newPassword) ? 1 : 0) + + (/[^\w\s]/.test(newPassword) ? 1 : 0); + if (newPassword.length >= 8 && kinds >= 2) { + if (lenScore && kinds >= 3) return 3; // 강 + return 2; // 보통 + } + return 1; // 약 + }, [newPassword]); + + const strengthText = ["", "약함", "보통", "강함"][passwordStrength]; - const handleSendCode = async () => { - if (!email) { - Alert.alert("오류", "이메일을 입력해주세요."); + // 1단계: 이메일로 인증번호 요청 + const handleRequestCode = async () => { + if (isLoading) return; + + if (!validateEmail(email)) { + Alert.alert("오류", "올바른 이메일 형식을 입력해주세요."); return; } - setLoading(true); + setIsLoading(true); + try { - const response = await fetch( - `${API_BASE_URL}users/password_reset/request/`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - } - ); - - // 응답 타입 확인 - const contentType = response.headers.get("content-type"); - if (!contentType || !contentType.includes("application/json")) { - const textResponse = await response.text(); - console.error("서버 응답이 JSON이 아님:", textResponse); - Alert.alert("오류", "서버 응답 형식 오류"); - setLoading(false); - return; + const response = await fetch(`${API_BASE_URL}users/password_reset/request/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email.trim().toLowerCase(), + }), + }); + + 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) { + console.error("🚨 Network Error:", error); + Alert.alert("오류", "네트워크 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + // 인증번호 입력 처리 + 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); + } + }; + + // 2단계: 인증번호 + 새 비밀번호 검증 + const handleResetPassword = async () => { + if (isVerifying) return; + + const enteredCode = code.join(""); + + if (enteredCode.length !== 6) { + Alert.alert("오류", "6자리 인증번호를 모두 입력해주세요."); + return; + } + + if (!passwordValid(newPassword)) { + Alert.alert("오류", "비밀번호는 8~32자이며, 영문/숫자/특수 중 2가지 이상을 포함해야 합니다."); + return; + } + + if (newPassword !== confirmPassword) { + Alert.alert("오류", "비밀번호가 일치하지 않습니다."); + return; + } + + setIsVerifying(true); + + try { + const response = await fetch(`${API_BASE_URL}users/password_reset/verify/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email.trim().toLowerCase(), + code: enteredCode, + new_password: newPassword, + }), + }); const data = await response.json(); + console.log("✅ 비밀번호 찾기 응답:", data); - if (response.ok) { + if (response.status === 200 || data.status === "success") { Alert.alert( "성공", - data.message || "비밀번호 재설정 링크를 이메일로 보냈습니다.", - [ - { - text: "다음", - onPress: () => - navigation.navigate("ResetPassword", { email: email }), - }, - ] + "비밀번호가 성공적으로 변경되었습니다.\n새 비밀번호로 로그인해주세요.", + [{ text: "확인", onPress: () => navigation.navigate("Login") }] ); } else { - Alert.alert("오류", data.message || "비밀번호 찾기에 실패했습니다."); + Alert.alert("오류", data.message || "비밀번호 재설정에 실패했습니다."); + // 인증번호 초기화 + setCode(["", "", "", "", "", ""]); + if (codeInputs.current[0]) { + codeInputs.current[0].focus(); + } } } catch (error) { - console.error("🚨 Network Error:", error); + console.error("🚨 비밀번호 찾기 오류:", error); Alert.alert("오류", "네트워크 오류가 발생했습니다."); } finally { - setLoading(false); + setIsVerifying(false); } }; + // 인증번호 재전송 + const handleResendCode = async () => { + setCode(["", "", "", "", "", ""]); + await handleRequestCode(); + }; + + // 단계별 제출 가능 여부 + const canSubmitStep1 = validateEmail(email); + const canSubmitStep2 = + code.join("").length === 6 && + passwordValid(newPassword) && + newPassword === confirmPassword; + return ( - - {/* 🔙 뒤로 가기 버튼 */} - navigation.goBack()} - style={styles.backButton} + + - {"<"} - - - {/* 🏷 타이틀 */} - 비밀번호 찾기 - - {/* 📧 이메일 입력 */} - 이메일 - - - + step === 1 ? navigation.goBack() : setStep(1)} + style={styles.backButton} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + {"<"} + + 비밀번호 찾기 + + + + - - {loading ? "전송 중..." : "전송"} - - - - - - 가입하신 이메일로 비밀번호 재설정 링크가 발송됩니다. - - + {step === 1 ? ( + // 1단계: 이메일 입력 + <> + 이메일 주소 입력 + + 가입 시 사용한 이메일 주소를 입력하시면{"\n"} + 비밀번호 재설정 인증번호를 보내드립니다. + + + 이메일 + 0 && !validateEmail(email) ? styles.inputError : null + ]} + placeholder="이메일 입력" + placeholderTextColor="#bcd1d6" + keyboardType="email-address" + autoCapitalize="none" + value={email} + onChangeText={(t) => setEmail((t || "").trimStart())} + returnKeyType="done" + onSubmitEditing={handleRequestCode} + /> + {email.length > 0 && !validateEmail(email) && ( + 올바른 이메일 형식이 아닙니다. + )} + + + + ) : ( + // 2단계: 인증번호 + 새 비밀번호 + <> + 인증번호 및 새 비밀번호 입력 + + {email} 주소로 전송된{"\n"} + 인증번호 6자리와 새 비밀번호를 입력해주세요. + + + {/* 인증번호 입력 */} + 인증번호 + + {code.map((digit, index) => ( + (codeInputs.current[index] = ref)} + style={styles.codeInput} + value={digit} + onChangeText={(text) => handleCodeChange(text, index)} + keyboardType="number-pad" + maxLength={1} + textAlign="center" + /> + ))} + + + + 인증번호 다시 보내기 + + + {/* 새 비밀번호 */} + 새 비밀번호 + 0 && !passwordValid(newPassword) ? styles.inputError : null + ]}> + refConfirmPw.current && refConfirmPw.current.focus()} + /> + setSeeNewPassword((v) => !v)} style={styles.icon}> + {seeNewPassword ? : } + + + + {/* 강도 표시 */} + {newPassword.length > 0 && ( + + = 1 && styles.strengthOn]} /> + = 2 && styles.strengthOn]} /> + = 3 && styles.strengthOn]} /> + {strengthText} + + )} + {newPassword.length > 0 && !passwordValid(newPassword) && ( + 영문/숫자/특수 중 2종 이상, 8~32자 + )} + 영문 대/소문자·숫자·특수 중 2가지 이상, 8~32자 + + {/* 비밀번호 확인 */} + 새 비밀번호 확인 + 0 && confirmPassword !== newPassword ? styles.inputError : null + ]}> + + setSeeConfirmPassword((v) => !v)} style={styles.icon}> + {seeConfirmPassword ? : } + + + {confirmPassword.length > 0 && confirmPassword !== newPassword && ( + 비밀번호가 일치하지 않아요. + )} + {confirmPassword.length > 0 && newPassword === confirmPassword && ( + 비밀번호가 일치합니다. + )} + + + + )} + + + {/* 제출 버튼 */} + + + {(step === 1 ? isLoading : isVerifying) ? ( + + ) : ( + + {step === 1 ? "인증번호 전송" : "비밀번호 찾기"} + + )} + + + + ); }; -// ✅ 스타일 정의 const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#003340", + safe: { flex: 1, backgroundColor: "#003340" }, + flex: { flex: 1 }, + + header: { + height: 56, + flexDirection: "row", alignItems: "center", - justifyContent: "center", - paddingHorizontal: 30, + justifyContent: "space-between", + paddingHorizontal: 16, + backgroundColor: "#003340", }, + backButton: { width: 36, height: 36, alignItems: "center", justifyContent: "center" }, + backText: { fontSize: 28, color: "#F074BA", marginTop: -2 }, + title: { fontSize: 20, fontWeight: "bold", color: "#F074BA" }, - backButton: { - position: "absolute", - top: 50, - left: 20, - zIndex: 10, - }, - backText: { - fontSize: 36, - color: "#F074BA", - }, + scroll: { flex: 1 }, + scrollContent: { paddingHorizontal: 24, paddingTop: 8, paddingBottom: 8 }, - title: { - fontSize: 24, + stepTitle: { + fontSize: 22, fontWeight: "bold", color: "#F074BA", - position: "absolute", - top: 150, - left: 30, + textAlign: "center", + marginTop: 20, + marginBottom: 10, + }, + stepDescription: { + fontSize: 15, + color: "#cfe7ec", + textAlign: "center", + marginBottom: 30, + lineHeight: 22, }, - label: { - fontSize: 16, - color: "#F074BA", - alignSelf: "flex-start", - marginTop: 10, + label: { fontSize: 15, color: "#F074BA", marginTop: 12, marginBottom: 8 }, + + input: { + width: "100%", + height: 50, + borderWidth: 1, + borderColor: "#87a9b1", + borderRadius: 10, + paddingHorizontal: 14, marginBottom: 10, + fontSize: 16, + backgroundColor: "#f1f6f7", + color: "#0a0a0a", }, + inputError: { borderColor: "#ff8a8a" }, inputContainer: { flexDirection: "row", alignItems: "center", width: "100%", borderWidth: 1, - borderColor: "#ddd", - borderRadius: 8, - backgroundColor: "#f9f9f9", + borderColor: "#87a9b1", + borderRadius: 10, + backgroundColor: "#f1f6f7", marginBottom: 10, - paddingHorizontal: 10, - }, - - input: { - flex: 1, - height: 50, - fontSize: 16, - color: "black", + paddingHorizontal: 6, }, + inputField: { flex: 1, height: 50, fontSize: 16, color: "#0a0a0a", paddingHorizontal: 8 }, + icon: { padding: 10 }, - sendButton: { - width: 60, - height: 35, - alignItems: "center", + // 인증번호 입력 + codeContainer: { + flexDirection: "row", justifyContent: "center", - backgroundColor: "#CCCDD0", - borderRadius: 16, - marginLeft: 10, + gap: 8, + marginBottom: 20, }, - - disabledButton: { - backgroundColor: "#A0A0A0", + codeInput: { + width: 45, + height: 50, + borderWidth: 1, + borderColor: "#87a9b1", + borderRadius: 8, + fontSize: 20, + color: "#0a0a0a", + backgroundColor: "#f1f6f7", + textAlign: "center", }, - sendButtonText: { + resendButton: { + alignSelf: "center", + marginBottom: 20, + }, + resendText: { + color: "#F074BA", fontSize: 14, - color: "black", + textDecorationLine: "underline", }, - infoText: { - fontSize: 14, - color: "#F074BA", - textAlign: "center", - marginTop: 20, - opacity: 0.7, + errorText: { color: "tomato", fontSize: 12, marginBottom: 6, marginLeft: 2 }, + passwordGuide: { fontSize: 12, color: "#cfe7ec", marginBottom: 6, marginLeft: 2 }, + passwordMatch: { fontSize: 12, color: "#00e676", marginBottom: 6, marginLeft: 2 }, + + // 비밀번호 강도 + strengthRow: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 6, marginLeft: 2 }, + strengthBar: { width: 32, height: 6, borderRadius: 4, backgroundColor: "#6e8f98" }, + strengthOn: { backgroundColor: "#F074BA" }, + strengthText: { color: "#cfe7ec", fontSize: 12, marginLeft: 6 }, + + // 푸터 버튼 + footer: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 24, + paddingTop: 8, + paddingBottom: 16, + backgroundColor: "rgba(0, 51, 64, 0.92)", + }, + button: { + height: 52, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", }, + buttonText: { color: "#fff", fontSize: 18, fontWeight: "bold" }, }); -export default FindPasswordScreen; +export default FindPasswordScreen; \ No newline at end of file diff --git a/src/screens/Auth/LoginScreen.js b/src/screens/Auth/LoginScreen.js index 7e353fa..666bf3c 100644 --- a/src/screens/Auth/LoginScreen.js +++ b/src/screens/Auth/LoginScreen.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { StyleSheet, Text, @@ -6,7 +6,11 @@ import { View, TouchableOpacity, Alert, - Image, + KeyboardAvoidingView, + Platform, + Keyboard, + ScrollView, + Animated, } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import EyeOpen from "../../components/EyeOpen"; @@ -14,13 +18,63 @@ import EyeClosed from "../../components/EyeClosed"; import { API_BASE_URL, API_ENDPOINTS } from "../../utils/apiConfig"; import { registerPushToken } from "../../services/PushNotificationService"; - - const LoginScreen = ({ navigation }) => { const [seePassword, setSeePassword] = useState(true); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [keyboardVisible, setKeyboardVisible] = useState(false); + + const titleOpacity = useState(new Animated.Value(1))[0]; + const titleTranslateY = useState(new Animated.Value(0))[0]; + + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener( + "keyboardDidShow", + handleKeyboardShow + ); + const keyboardDidHideListener = Keyboard.addListener( + "keyboardDidHide", + handleKeyboardHide + ); + + return () => { + keyboardDidShowListener?.remove(); + keyboardDidHideListener?.remove(); + }; + }, []); + + const handleKeyboardShow = () => { + setKeyboardVisible(true); + Animated.parallel([ + Animated.timing(titleOpacity, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(titleTranslateY, { + toValue: -30, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + }; + + const handleKeyboardHide = () => { + setKeyboardVisible(false); + Animated.parallel([ + Animated.timing(titleOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(titleTranslateY, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + }; const handleLogin = async () => { if (!email || !password) { @@ -67,13 +121,13 @@ const LoginScreen = ({ navigation }) => { await AsyncStorage.setItem("accessToken", access); await AsyncStorage.setItem("refreshToken", refresh); await AsyncStorage.setItem("userEmail", email); - await AsyncStorage.setItem("userPassword", password); // ❗ 자동 로그인을 위해 password도 저장 + await AsyncStorage.setItem("userPassword", password); - await AsyncStorage.setItem( "hasCompletedTutorial", has_completed_tutorial.toString() ); + try { const pushTokenSuccess = await registerPushToken(navigation); if (pushTokenSuccess) { @@ -83,11 +137,8 @@ const LoginScreen = ({ navigation }) => { } } catch (pushError) { console.error(" Push Token 등록 중 오류:", pushError); - // Push Token 등록 실패해도 로그인은 계속 진행 } - // 튜토리얼 완료 여부 저장 - if (has_completed_tutorial) { console.log("🔹 튜토리얼 완료 → MainTab 이동"); navigation.navigate("MainTab"); @@ -95,12 +146,8 @@ const LoginScreen = ({ navigation }) => { console.log("🔹 튜토리얼 미완료 → TutorialScreen 이동"); navigation.navigate("TutorialScreen", { fromLogin: true }); } - - // console.log("🔹 로그인 성공, MainTab으로 이동 시도"); - // navigation.navigate("MainTab"); } else { console.log("❌ 로그인 실패:", data); - Alert.alert( "오류", "로그인 정보가 일치하지 않습니다.\n다시 확인해 주세요." @@ -115,71 +162,110 @@ const LoginScreen = ({ navigation }) => { }; return ( - - 로그인 - - 이메일 - + + + + 로그인 + - 비밀번호 - - - setSeePassword(!seePassword)} - style={styles.icon} + - {seePassword ? : } - - + + 이메일 + + + + - - - {isLoading ? "로그인 중..." : "로그인"} - - + + 비밀번호 + + + + setSeePassword(!seePassword)} + style={styles.eyeIcon} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + {seePassword ? : } + + + + - - {/* navigation.navigate("FindId")} - style={styles.findIdButton} - > - 이메일 찾기 - */} + + + {isLoading ? "로그인 중..." : "로그인"} + + - navigation.navigate("FindPassword")} - style={styles.findPasswordButton} - > - 비밀번호 찾기 - + + navigation.navigate("FindPassword")} + style={styles.linkButton} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + 비밀번호 찾기 + - navigation.navigate("SignUp1")} - style={styles.signUpButton} - > - 회원가입 - - - + + + navigation.navigate("SignUp1")} + style={styles.linkButton} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + 회원가입 + + + + + ); }; @@ -187,102 +273,122 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#003340", - alignItems: "center", - justifyContent: "center", + }, + scrollContainer: { + flexGrow: 1, paddingHorizontal: 30, }, + titleContainer: { + paddingTop: 130, + paddingBottom: 50, + alignItems: "flex-start", + }, title: { - fontSize: 24, + fontSize: 25, fontWeight: "bold", color: "#F074BA", - position: "absolute", - top: 150, - left: 30, + letterSpacing: 0.5, + }, + inputSection: { + flex: 1, + justifyContent: "center", + paddingBottom: 60, + }, + inputSectionKeyboard: { + justifyContent: "flex-start", + paddingTop: 20, + }, + inputGroup: { + marginBottom: 24, }, label: { fontSize: 16, color: "#F074BA", - alignSelf: "flex-start", - marginTop: 10, - marginBottom: 10, + marginBottom: 8, + fontWeight: "500", + }, + inputWrapper: { + borderRadius: 12, + backgroundColor: "#f9f9f9", + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, }, input: { - width: "100%", - height: 50, - borderWidth: 1, - borderColor: "#ddd", - borderRadius: 8, - paddingHorizontal: 15, - marginBottom: 5, + height: 52, + paddingHorizontal: 16, fontSize: 16, - backgroundColor: "#f9f9f9", - color: "black", + color: "#333", + backgroundColor: "transparent", }, - inputContainer: { + passwordContainer: { flexDirection: "row", alignItems: "center", - width: "100%", - borderWidth: 1, - borderColor: "#ddd", - borderRadius: 8, - backgroundColor: "#f9f9f9", - marginBottom: 15, - paddingHorizontal: 10, + height: 52, }, - inputField: { + passwordInput: { flex: 1, - height: 50, + paddingHorizontal: 16, fontSize: 16, - color: "black", + color: "#333", }, - icon: { - padding: 10, + eyeIcon: { + paddingHorizontal: 16, + paddingVertical: 16, }, - button: { - width: "100%", - height: 50, + loginButton: { + height: 52, backgroundColor: "#F074BA", - borderRadius: 8, + borderRadius: 12, alignItems: "center", justifyContent: "center", - position: "absolute", - bottom: 80, + marginTop: 16, + shadowColor: "#F074BA", + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, }, - buttonDisabled: { + loginButtonDisabled: { backgroundColor: "#d3d3d3", + shadowOpacity: 0.1, }, - buttonText: { + loginButtonText: { color: "#fff", fontSize: 18, - fontWeight: "bold", + fontWeight: "600", + letterSpacing: 0.5, }, - buttonContainer: { + linkContainer: { flexDirection: "row", - marginTop: 10, - paddingHorizontal: 10, - }, - findIdButton: { + justifyContent: "center", alignItems: "center", - marginRight: 10, + marginTop: 24, + paddingBottom: 20, }, - findIdText: { - color: "#EFF1F5", - fontSize: 16, + linkButton: { + paddingVertical: 8, + paddingHorizontal: 12, }, - findPasswordButton: { - alignItems: "flex-start", - marginRight: 180, - }, - findPasswordText: { + linkText: { color: "#EFF1F5", fontSize: 16, + textDecorationLine: "underline", }, - signUpButton: { - alignItems: "center", - }, - signUpText: { - color: "#EFF1F5", - fontSize: 16, + linkSeparator: { + width: 1, + height: 16, + backgroundColor: "#EFF1F5", + marginHorizontal: 16, + opacity: 0.5, }, }); diff --git a/src/screens/Auth/SignUp1Screen.js b/src/screens/Auth/SignUp1Screen.js index f4f980b..1dcdeb4 100644 --- a/src/screens/Auth/SignUp1Screen.js +++ b/src/screens/Auth/SignUp1Screen.js @@ -6,84 +6,259 @@ import CheckBoxUnchecked from '../../components/CheckBoxUnchecked'; const SignUp1Screen = ({ navigation }) => { const [agreements, setAgreements] = useState({ all: false, + required1: false, // 두둑 이용 약관 + //required2: false, // 개인정보 수집·이용 동의 + required6: false, // 만 14세 이상 + optional2: false, // 광고성 정보 수신 동의 + }); + + const [expandedStates, setExpandedStates] = useState({ required1: false, - required2: false, - required3: false, - required4: false, - required5: false, + //required2: false, required6: false, - optional1: false, optional2: false, }); + + const termsData = { + required1: { + title: '[필수] 두둑 이용 약관', + content: `**제1조 (목적)** +이 약관은 햄듭니다(이하 "회사")가 제공하는 ‘두둑(Doodook)’ 서비스의 이용조건 및 절차, 회사와 회원 간의 권리·의무 및 책임사항을 규정함을 목적으로 합니다. + +**제2조 (정의)** +"서비스"란 사용자가 가상의 자산을 활용해 투자 시뮬레이션을 경험하고, 금융학습 및 포트폴리오 분석 기능을 이용할 수 있는 플랫폼을 의미합니다. +"회원"이란 본 약관에 동의하고 서비스에 가입한 자를 의미합니다. +"AI 챗봇"이란 OpenAI 기반 투자 정보 응답 기능을 말하며, 정보 제공 목적에 한정됩니다. + +**제3조 (약관의 효력 및 변경)** +본 약관은 서비스 초기화면 또는 설정 화면 등에 게시하여 공지함으로써 효력을 발생합니다. +회사는 필요 시 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있으며, 변경 시 사전 고지합니다. + +**제4조 (회원가입 및 이용 계약 체결)** +회원가입은 만 14세 이상인 자에 한해 가능합니다. +사용자는 본인의 이메일 주소를 통해 계정을 생성해야 하며, 허위 정보 입력 시 이용이 제한될 수 있습니다. +회원가입 시 시스템에서 제공하는 인증 절차(이메일 토큰, 코드 인증 등)를 완료해야 합니다. + +**제5조 (서비스의 제공 및 기능)** +회사는 다음과 같은 기능을 제공합니다: +- 가상의 금액으로 매수/매도 가능한 투자 시뮬레이션 기능 +- 가상 포트폴리오 관리 및 수익률 조회 +- 관심 종목 저장 및 종목 검색 기능 +- 투자 성향 분석 테스트 +- AI 챗봇을 통한 투자 정보 안내 (OpenAI 기반) +- 학습 가이드 기능 및 단계별 콘텐츠 +- 주가 변동 등 주요 이벤트에 대한 푸시 알림 제공 +- 외부 API 연동: 한국투자증권 API, OpenAI API 등 + +**제6조 (서비스 이용 조건)** +서비스는 무료로 제공되며, 유료 기능은 현재 제공되지 않습니다. +투자 시뮬레이션은 실제 주식 투자나 금융 자산 운용과는 무관하며, 정보 제공 및 학습 목적으로만 활용되어야 합니다. +AI 챗봇의 응답은 참고용으로만 제공되며, 특정 종목에 대한 투자 권유 또는 금융 자문에 해당하지 않습니다. + +**제7조 (회원의 의무)** +회원은 서비스 이용 시 관계 법령, 본 약관의 규정, 회사의 안내사항 등을 준수해야 합니다. +회원은 본인의 계정을 제3자에게 양도하거나 공유할 수 없습니다. +서비스의 기능을 부정하게 이용하거나, 시스템을 비정상적으로 접근·변조하는 행위는 금지됩니다. + +**제8조 (개인정보의 수집 및 보호)** +회사는 회원가입 및 서비스 제공에 필요한 최소한의 개인정보를 수집합니다. +수집 항목: 이메일, 닉네임, 생년월일, 성별, 주소, 잔고, 튜토리얼 진행 여부 등 +개인정보는 암호화 및 보안 조치를 통해 안전하게 관리되며, 자세한 내용은 별도의 [개인정보처리방침]에 따릅니다. + +**제9조 (계정 해지 및 데이터 삭제)** +회원은 언제든지 앱 내 제공되는 탈퇴 기능을 통해 계정을 삭제할 수 있습니다. +회원 탈퇴 시 해당 계정과 관련된 모든 데이터는 즉시 삭제됩니다. 단, 관련 법령에 따른 예외 보관 항목은 제외합니다. + +**제10조 (서비스 변경 및 종료)** +회사는 기능 개선, 시스템 유지보수, 외부 정책 변경 등에 따라 서비스의 일부 또는 전부를 변경·중단할 수 있습니다. +서비스 종료 시 사전 고지하며, 회원 데이터는 관련 법령에 따라 안전하게 처리됩니다. + +**제11조 (책임의 한계)** +회사는 회원이 서비스 내 제공된 정보를 바탕으로 실제 투자 결정을 내린 경우, 그에 따른 손실에 대해 책임지지 않습니다. +외부 API(한국투자증권, OpenAI 등)로부터 제공되는 데이터의 정확성 및 실시간성은 보장되지 않을 수 있습니다. + +**제12조 (지식재산권)** +서비스 내 콘텐츠, 소스코드, UI 등 모든 저작권은 회사에 귀속됩니다. +회원은 회사의 명시적 허락 없이 콘텐츠를 복제, 전송, 배포, 가공, 판매할 수 없습니다. + +**제13조 (분쟁 해결 및 준거법)** +회사와 회원 간 발생한 분쟁에 대해 원만한 해결을 위해 성실히 협의합니다. +분쟁이 해결되지 않을 경우, 대한민국 법령을 적용하며, 민사소송의 관할 법원은 서울중앙지방법원으로 합니다. + +**부칙** +본 약관은 2025년 9월 30일부터 시행됩니다.` + }, +// required2: { +// title: '[필수] 개인정보 수집·이용 동의', +// content: `**수집하는 개인정보 항목** +// - 필수항목: 이메일, 닉네임, 생년월일, 성별 + +// **개인정보 수집 및 이용 목적** +// 1. 회원가입 의사 확인 및 회원제 서비스 제공 +// 2. 투자 시뮬레이션 데이터 관리 +// 3. 서비스 이용 안내 및 문의사항 응답 +// 4. 부정 이용 방지 및 서비스 개선 + +// **개인정보 보유 및 이용 기간** +// - 회원 탈퇴 시까지 보유하며, 탈퇴 후 즉시 삭제됩니다. (단, 관련 법령에 따른 예외는 제외) + +// **동의를 거부할 권리** +// 귀하는 개인정보 수집·이용에 대한 동의를 거부할 권리가 있습니다. 다만, 필수항목에 대한 동의를 거부할 경우 서비스 이용이 제한될 수 있습니다.` +// }, + required6: { + title: '[필수] 만 14세 이상입니다.', + content: `본 서비스는 만 14세 이상의 사용자만 이용할 수 있습니다. + +**확인 사항** +- 만 14세 미만의 경우 법정대리인의 동의가 필요합니다. +- 허위 연령 정보 입력 시 서비스 이용이 제한될 수 있습니다. + +**관련 법령** +「개인정보 보호법」, 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」에 따른 만 14세 미만 아동의 개인정보 처리 제한 규정을 준수합니다.` + }, + optional2: { + title: '[선택] 광고성 정보 수신 동의', + content: `**광고성 정보 수신 목적** +두둑 서비스의 새로운 기능이나 공지사항 업데이트 등의 정보를 Push 알림으로 보내드립니다. + +**수신 방법** +- 앱 푸시 알림 + +**수신 거부** +- 기기 내 설정 > 두둑 앱 권한 설정에서 Push 알림을 허용하지 않습니다. + +**동의 거부 시 불이익** +광고성 정보 수신에 동의하지 않아도 두둑 서비스의 기본 기능 이용에는 전혀 영향이 없습니다.` + } + }; + const toggleAll = () => { const newState = !agreements.all; setAgreements({ all: newState, required1: newState, required2: newState, - required3: newState, - required4: newState, - required5: newState, required6: newState, - optional1: newState, optional2: newState, }); + setExpandedStates({ + required1: false, + required2: false, + required6: false, + optional2: false, + }); + }; + + const toggleExpanded = (key) => { + setExpandedStates(prev => ({ + ...prev, + [key]: !prev[key] + })); }; - const toggleItem = (key) => { + 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 handleAgreeAndCollapse = (key) => { + if (!agreements[key]) { + toggleAgreement(key); + } + setExpandedStates(prev => ({ ...prev, [key]: false })); + }; + + const handleCheckboxPress = (key) => { + toggleAgreement(key); + setExpandedStates(prev => ({ ...prev, [key]: false })); + }; + + const renderFormattedContent = (content) => { + const parts = content.split('**'); + return ( + + {parts.map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + part + ) + )} + + ); + }; + + const renderTermsItem = (key) => { + const term = termsData[key]; + const isExpanded = expandedStates[key]; + const isChecked = agreements[key]; + + return ( + + + handleCheckboxPress(key)}> + {isChecked ? : } + + toggleExpanded(key)} style={styles.titleContainer}> + {term.title} + + {isExpanded ? '▲' : '▼'} + + + + + {isExpanded && ( + + + {renderFormattedContent(term.content)} + + handleAgreeAndCollapse(key)} + > + 동의하기 + + + )} + + ); + }; + + const allRequiredAgreed = agreements.required1 && agreements.required2 && agreements.required6; return ( navigation.goBack()} style={styles.backButton}> {'<'} + 이용 약관에{"\n"}동의해 주세요 + {agreements.all ? : } 전체 동의 + - {[ - { key: 'required1', label: '[필수] 두독 이용 약관' }, - { key: 'required2', label: '[필수] 개인정보 수집·이용 동의' }, - { key: 'required3', label: '[필수] 민감정보 수집·이용 동의' }, - { key: 'required4', label: '[필수] 개인정보 제3자 제공 동의' }, - { key: 'required5', label: '[필수] 개인정보 국외 이전 동의' }, - { key: 'required6', label: '[필수] 만 14세 이상입니다.' }, - { key: 'optional1', label: '[선택] 마케팅 활용 동의' }, - { key: 'optional2', label: '[선택] 광고성 정보 수신 동의' }, - ].map((item) => ( - toggleItem(item.key)} style={styles.agreeItem}> - {agreements[item.key] ? : } - {item.label} - - ))} + {Object.keys(termsData).map(key => renderTermsItem(key))} + navigation.navigate('SignUp2')} > 동의하기 @@ -97,12 +272,12 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#003340', paddingHorizontal: 30, - paddingTop: 60, + paddingTop: 30, }, backButton: { position: 'absolute', - top: 50, - left: 20, + top: 30, + left: 30, zIndex: 10, }, backText: { @@ -113,9 +288,8 @@ const styles = StyleSheet.create({ fontSize: 24, fontWeight: 'bold', color: '#F074BA', - position: 'absolute', - top: 150, - left: 30, + marginTop: 90, + marginBottom: 20, }, allAgree: { flexDirection: 'row', @@ -123,8 +297,6 @@ const styles = StyleSheet.create({ paddingVertical: 15, borderBottomWidth: 2, borderBottomColor: '#F074BA', - top: 150, - marginTop: 20, marginBottom: 10, }, allAgreeText: { @@ -134,10 +306,11 @@ const styles = StyleSheet.create({ marginLeft: 10, }, scrollView: { - flex: 1, - marginTop: 150, + flex: 1, marginBottom: 20, - maxHeight: 400, + }, + termsContainer: { + marginBottom: 10, }, agreeItem: { flexDirection: 'row', @@ -146,15 +319,57 @@ const styles = StyleSheet.create({ backgroundColor: '#FFFFFF', borderRadius: 15, paddingHorizontal: 15, - marginBottom: 10, - justifyContent: 'flex-start', + }, + agreeItemExpanded: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + titleContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginLeft: 10, }, agreeText: { + flex: 1, fontSize: 14, color: '#000', fontWeight: '500', + }, + expandIcon: { + fontSize: 12, + color: '#666', marginLeft: 10, }, + termsContent: { + backgroundColor: '#F8F9FA', + borderBottomLeftRadius: 15, + borderBottomRightRadius: 15, + }, + termsScrollView: { + maxHeight: 200, + paddingHorizontal: 15, + paddingTop: 15, + }, + termsText: { + fontSize: 12, + color: '#333', + lineHeight: 18, + marginBottom: 15, + }, + agreeButton: { + backgroundColor: '#F074BA', + marginHorizontal: 15, + marginVertical: 15, + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + }, + agreeButtonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: 'bold', + }, button: { width: '100%', height: 50, @@ -162,9 +377,7 @@ const styles = StyleSheet.create({ borderRadius: 8, alignItems: 'center', justifyContent: 'center', - position: 'absolute', - bottom: 80, - alignSelf: 'center', + marginBottom: 30, }, buttonDisabled: { backgroundColor: '#F8C7CC', @@ -177,3 +390,4 @@ const styles = StyleSheet.create({ }); export default SignUp1Screen; + diff --git a/src/screens/Auth/SignUp2Screen.js b/src/screens/Auth/SignUp2Screen.js index 230a5cb..081a30d 100644 --- a/src/screens/Auth/SignUp2Screen.js +++ b/src/screens/Auth/SignUp2Screen.js @@ -13,6 +13,7 @@ import { KeyboardAvoidingView, Platform, SafeAreaView, + Keyboard, } from "react-native"; import { API_BASE_URL } from "../../utils/apiConfig"; import EyeOpen from "../../components/EyeOpen"; @@ -57,6 +58,36 @@ const SignUp2Screen = ({ navigation }) => { const refCity = useRef(null); const refTown = useRef(null); const refDetail = useRef(null); + const scrollRef = useRef(null); + + // 키보드 열릴 때 하단 여유공간을 크게 확보 + const [keyboardVisible, setKeyboardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + const showSub = Keyboard.addListener("keyboardDidShow", (e) => { + setKeyboardVisible(true); + setKeyboardHeight(e?.endCoordinates?.height ?? 0); + }); + const hideSub = Keyboard.addListener("keyboardDidHide", () => { + setKeyboardVisible(false); + setKeyboardHeight(0); + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + // 기본 버튼 영역 96 + 키보드가 보이면 키보드 높이 + 여백 120 + const bottomSpacer = useMemo(() => { + if (!keyboardVisible) return 120; // 평소에도 넉넉히 + return Math.max(220, keyboardHeight + 140); + }, [keyboardVisible, keyboardHeight]); + + + + // validators const validateEmail = (e) => @@ -270,7 +301,7 @@ const SignUp2Screen = ({ navigation }) => { {/* 헤더 */} @@ -315,6 +346,7 @@ const SignUp2Screen = ({ navigation }) => { placeholder="비밀번호 입력" placeholderTextColor="#bcd1d6" secureTextEntry={seePassword} + showSoftInputOnFocus={false} value={password} onChangeText={setPassword} returnKeyType="next" @@ -347,6 +379,7 @@ const SignUp2Screen = ({ navigation }) => { placeholderTextColor="#bcd1d6" secureTextEntry={seeConfirmPassword} value={confirmPassword} + //showSoftInputOnFocus={false} onChangeText={setConfirmPassword} returnKeyType="next" onSubmitEditing={() => refNick.current && refNick.current.focus && refNick.current.focus()} @@ -494,6 +527,12 @@ const SignUp2Screen = ({ navigation }) => { onChangeText={setAddrTown} returnKeyType="next" onSubmitEditing={() => refDetail.current && refDetail.current.focus && refDetail.current.focus()} + onFocus={() => { + if (!showMoreAddr) setShowMoreAddr(true); + requestAnimationFrame(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }); + }} /> { value={addrDetail} onChangeText={setAddrDetail} returnKeyType="done" + + + onFocus={() => { + if (!showMoreAddr) setShowMoreAddr(true); + requestAnimationFrame(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }); + }} + + /> )} {/* 하단 여백: 버튼 공간 확보 */} - + {/* 제출 버튼 */} diff --git a/src/screens/Chatbot/ChatbotScreen.js b/src/screens/Chatbot/ChatbotScreen.js index 174b58c..8413fa5 100644 --- a/src/screens/Chatbot/ChatbotScreen.js +++ b/src/screens/Chatbot/ChatbotScreen.js @@ -1,4 +1,5 @@ -import React, { useState, useRef } from 'react'; +// ChatbotScreen.js +import React, { useState, useRef, useCallback, useEffect } from "react"; import { View, Text, @@ -9,337 +10,685 @@ import { Platform, ScrollView, ActivityIndicator, -} from 'react-native'; - -import { chatbotReply } from '../../utils/chatbotReply'; + Animated, + Dimensions, + Keyboard, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import SearchIcon from "../../assets/icons/search.svg"; +import { chatbotReply } from "../../utils/chatbotReply"; -// const ChatbotScreen = () => { -// console.log('ChatbotScreen 렌더링'); -// return ( -// -// Chatbot Screen -// -// ); -// }; - -// const ChatbotScreen = () => { -// return ( - -// -// -// -// 안녕하세요! 무엇을 도와드릴까요? -// - -// -// ETF 투자가 뭐야? -// - -// -// -// ETF 투자를 생각해보면, 이게 마치 쇼핑몰에서 장바구니에 여러 가지 상품을 담는 것과 비슷해요. ETF는 주식, 채권 등 다양한 자산으로 이루어져 있어요. 이걸 사는 것은 그 장바구니 전체를 한 번에 사는 것이랑 같아요. 그래서 요즘은 이런 ETF 투자를 많이 추천하는데, 그 이유는 한 번에 많은 종목의 주식을 사는 것보다 위험을 분산시킬 수 있기 때문이에요. 이렇게 해서 여러 종목의 주식을 한 번에 관리할 수 있어요! -// -// - -// -// ETF 투자가 뭐야? ETF 투자가 뭐야? -// - -// -// -// 이랑 같아요. 그래서 요즘은 이런 ETF 투자를 많이 추천하는데, 그 이유는 한 번에 많은 -// -// - - - - - -// - -// -// -// # -// -// -// -// 🔍 -// -// -// -// ); -// }; +const { width: SCREEN_WIDTH } = Dimensions.get("window"); +const INPUT_BAR_HEIGHT = 70; +const INPUT_FONT_SIZE = 16; +const GAP_FROM_TAB = 0; const ChatbotScreen = () => { + const insets = useSafeAreaInsets(); + const tabBarHeight = useBottomTabBarHeight(); + const [messages, setMessages] = useState([ - { sender: 'bot', text: '안녕하세요! 무엇을 도와드릴까요?' }, + { + sender: "bot", + text: "안녕하세요! 투자에 대해 궁금한 것이 있으시면 언제든 물어보세요 ✨", + timestamp: Date.now(), + }, ]); - const [input, setInput] = useState(''); - const scrollRef = useRef(); + const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); + const [inputHeight, setInputHeight] = useState(44); + const [keyboardHeight, setKeyboardHeight] = useState(0); -// const suggestions = [ -// "인기 주식", "AI 추천 종목", "장 초반 이슈", "대장주", -// "어제의 급상승 10", "코스피/코스닥 상승률", "배당주 추천", "검색 급상승 종목" -// ]; - -const suggestions = [ - "주식 시작", "PER", "배당금", "우량주", "선물", - "호가창", "분산투자가 왜 필요해?", "상장폐지", - "코스피랑 코스닥 차이점", "공매도", "주식 거래 시간", - "평단가", "매수&매도", "시가", "수수료", "ETF" -]; - - // const suggestions = [ - // "ETF 투자가 뭐야?", - // "삼성전자 주식 어때?", - // "카카오 실적은 어때?", - // "요즘 뜨는 산업 알려줘", - // "AI 관련주 알려줘", - // ]; - -const sendMessage = async () => { - if (!input.trim()) return; - - const userMsg = { sender: 'user', text: input }; - const loadingMsg = { sender: 'bot', text: '...' }; - - setMessages((prev) => [...prev, userMsg, loadingMsg]); - setInput(''); - setLoading(true); - - setTimeout(() => { - scrollRef.current?.scrollToEnd({ animated: true }); - }, 100); - - const reply = await chatbotReply(input); - - console.log("🤖 chatbotReply:", reply); - console.log("📏 길이:", reply.length); - - setMessages((prev) => { - const newMessages = [...prev]; - newMessages.pop(); // loading 메시지 제거 - return [...newMessages, { sender: 'bot', text: reply }]; - }); - - setLoading(false); - - // 스크롤 - setTimeout(() => { - scrollRef.current?.scrollToEnd({ animated: true }); - }, 100); -}; - + const scrollRef = useRef(null); + const fadeAnim = useRef(new Animated.Value(0)).current; + const slideAnim = useRef(new Animated.Value(50)).current; + + const suggestions = [ + { text: "주식 투자 시작하기", icon: "📈", category: "기초" }, + { text: "PER과 PBR 차이점", icon: "📊", category: "지표" }, + { text: "배당주 추천해줘", icon: "💰", category: "투자" }, + { text: "분산투자 전략", icon: "🎯", category: "전략" }, + { text: "코스피 vs 코스닥", icon: "🛍️", category: "시장" }, + { text: "ETF란 무엇인가요?", icon: "📦", category: "상품" }, + { text: "공매도 원리", icon: "📉", category: "거래" }, + { text: "주식 거래 시간", icon: "⏰", category: "기초" }, + ]; + + // 키보드 이벤트 리스너 + useEffect(() => { + const keyboardWillShow = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + (event) => { + setKeyboardHeight(event.endCoordinates.height); + } + ); + + const keyboardWillHide = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + () => { + setKeyboardHeight(0); + } + ); + + return () => { + keyboardWillShow.remove(); + keyboardWillHide.remove(); + }; + }, []); + + React.useEffect(() => { + if (showSuggestions) { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 50, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + } + }, [showSuggestions]); + + 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); + setInputHeight(44); + + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 100); + + try { + const reply = await chatbotReply(messageText); + setMessages((prev) => { + const next = [...prev]; + next.pop(); + return [ + ...next, + { sender: "bot", text: reply, timestamp: Date.now() }, + ]; + }); + } catch { + setMessages((prev) => { + const next = [...prev]; + next.pop(); + return [ + ...next, + { + sender: "bot", + text: "죄송해요, 잠시 후 다시 시도해주세요 🙏", + timestamp: Date.now(), + }, + ]; + }); + } finally { + setLoading(false); + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + }, + [input] + ); + // 동적 높이 계산 + const dynamicInputBarHeight = Math.max(INPUT_BAR_HEIGHT, inputHeight + 26); + const bottomOffset = keyboardHeight > 0 ? 0 : tabBarHeight + GAP_FROM_TAB; + + // 추천 질문 컨테이너의 bottom 위치 계산 (키보드 높이 포함) + const suggestionBottomPosition = keyboardHeight > 0 + ? keyboardHeight + dynamicInputBarHeight + 12 + : bottomOffset + dynamicInputBarHeight + 12; + + const TypingIndicator = () => { + const dot1Anim = useRef(new Animated.Value(0.4)).current; + const dot2Anim = useRef(new Animated.Value(0.4)).current; + const dot3Anim = useRef(new Animated.Value(0.4)).current; + + 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(); + }, []); + + return ( + + + + + + ); + }; return ( - - + -{messages.map((msg, index) => ( - - {msg.text === '...' && msg.sender === 'bot' ? ( - - ) : ( - - {msg.text} - - )} - -))} - - - -{showSuggestions && ( - - {suggestions.map((item, idx) => ( - { - setInput(item); - setShowSuggestions(false); - }} - style={styles.suggestionPill} - > - {item} - - ))} - -)} - - setShowSuggestions((prev) => !prev)} -> - - - # - - - - - - - - {/* */} - - - - - + {/* Header */} + + + + AI Assistant + + 투자 전문 상담 + + + + {messages.map((msg, index) => { + const isUser = msg.sender === "user"; + const isTyping = msg.text === "typing"; + + return ( + + {!isUser && ( + + + 🤖 + + + )} + + + {isTyping ? ( + + ) : ( + + {msg.text} + + )} + + + {isUser && ( + + + 👤 + + + )} + + ); + })} + + + {/* Suggestions */} + {showSuggestions && ( + + + + + 💡 추천 질문 + + + + {suggestions.map((item, idx) => ( + sendMessage(item.text)} + style={styles.suggestionCard} + activeOpacity={0.7} + > + {item.icon} + {item.text} + {item.category} + + ))} + + + )} + + {/* Input Bar */} + + + setShowSuggestions((prev) => !prev)} + activeOpacity={0.7} + > + + {showSuggestions ? "✨" : "💡"} + + + + + { + const { height } = event.nativeEvent.contentSize; + const newHeight = Math.min(Math.max(height + 8, 44), 120); + setInputHeight(newHeight); + }} + returnKeyType="send" + onSubmitEditing={() => sendMessage()} + blurOnSubmit={false} + autoCorrect={false} + autoCapitalize="none" + multiline + maxLength={500} + textAlignVertical="top" + /> + + + sendMessage()} + activeOpacity={0.7} + style={[styles.sendButton, input.trim() && styles.sendButtonActive]} + disabled={!input.trim() || loading} + > + {loading ? ( + + ) : ( + + )} + + + + + ); }; const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#003340', + backgroundColor: "#003340", }, - chatContainer: { - flexGrow: 1, // ✅ 이 줄 추가! - paddingTop: 60, + + keyboardView: { + flex: 1, + }, + + header: { + paddingBottom: 20, paddingHorizontal: 20, - paddingBottom: 120, - }, - botMessage: { - backgroundColor: '#E0E6E7', - borderRadius: 10, - padding: 12, - alignSelf: 'flex-start', - marginBottom: 10, - maxWidth: '80%', // ✅ 유동 크기 제한 - marginRight: 30, // ✅ 오른쪽 여백 추가 - //flexShrink: 1, // ✅ 텍스트 넘칠 경우 줄이기 허용 - //flexWrap: 'wrap', // ✅ 텍스트 줄바꿈 허용 + alignItems: "center", + borderBottomWidth: 1, + borderBottomColor: "rgba(255,255,255,0.08)", + backgroundColor: "#003340", }, - botText: { - color: '#222', + + aiIndicator: { + flexDirection: "row", + alignItems: "center", + marginBottom: 4, + }, + + aiDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: "#fb9dd2ff", + marginRight: 8, + shadowColor: "#10b981", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 4, + elevation: 4, + }, + + aiText: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "600", + letterSpacing: 0.5, + }, + + headerSubtitle: { + color: "rgba(255,255,255,0.7)", + fontSize: 14, + letterSpacing: 0.3, + }, + + chatScroll: { + flex: 1, + backgroundColor: "#003340", + }, + + chatContainer: { + paddingTop: 20, + paddingHorizontal: 16, + }, + + messageWrapper: { + flexDirection: "row", + marginBottom: 16, + alignItems: "flex-end", + }, + + userMessageWrapper: { + justifyContent: "flex-end", + }, + + botMessageWrapper: { + justifyContent: "flex-start", + }, + + avatarContainer: { + marginHorizontal: 8, + }, + + botAvatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(16, 185, 129, 0.2)", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: "rgba(16, 185, 129, 0.3)", + }, + + userAvatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(230, 59, 246, 0.2)", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: "rgba(230, 59, 246, 0.3)", + }, + + avatarText: { + fontSize: 14, + }, + + messageBubble: { + maxWidth: SCREEN_WIDTH * 0.7, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 20, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + + botBubble: { + backgroundColor: "rgba(255, 255, 255, 0.95)", + borderBottomLeftRadius: 6, + shadowColor: "#000000", + }, + + userBubble: { + backgroundColor: "#fb9dd2ff", + borderBottomRightRadius: 6, + shadowColor: "#3b82f6", + }, + + messageText: { fontSize: 15, - lineHeight: 22, + lineHeight: 20, + letterSpacing: 0.2, }, - userMessage: { - backgroundColor: '#D567A1', - borderRadius: 10, - padding: 12, - alignSelf: 'flex-end', - marginBottom: 10, - maxWidth: '80%', + + botText: { + color: "#1F2937", }, + userText: { - color: 'white', - fontSize: 15, + color: "#FFFFFF", + fontWeight: "500", }, - inputBar: { - position: 'absolute', - bottom: 65, + + typingContainer: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 4, + }, + + typingDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: "#9CA3AF", + marginRight: 4, + }, + + // Suggestions + suggestionContainer: { + position: "absolute", left: 0, right: 0, - flexDirection: 'row', - padding: 18, - backgroundColor: '#003340', - alignItems: 'center', - }, - hashButton: { - width: 36, - height: 36, - borderRadius: 10, - backgroundColor: '#D567A1', - alignItems: 'center', - justifyContent: 'center', - marginRight: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + + suggestionBackground: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 51, 64, 0.95)", + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + + suggestionHeader: { + marginBottom: 12, }, - hashButtonActive: { - backgroundColor: '#738C93', // ✅ 눌렀을 때 조금 진한 핑크 예시 -}, + suggestionTitle: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "600", + letterSpacing: 0.3, + }, + + suggestionRow: { + paddingVertical: 8, + }, - hashText: { - color: 'white', - fontWeight: 'bold', + suggestionCard: { + backgroundColor: "rgba(255, 255, 255, 0.1)", + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 16, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.15)", + marginRight: 12, + minWidth: 140, + alignItems: "center", + shadowColor: "#000000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + + suggestionIcon: { fontSize: 20, + marginBottom: 4, }, - suggestionContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - bottom: 140, - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: 20, - gap: 8, // React Native >= 0.71 - backgroundColor: '#738C93', -}, - -suggestionPill: { - backgroundColor: '#e5e5e5', - paddingVertical: 8, - paddingHorizontal: 10, - borderRadius: 20, - marginRight: 8, - marginBottom: 4, - -}, + suggestionText: { + fontSize: 13, + color: "#FFFFFF", + fontWeight: "500", + textAlign: "center", + lineHeight: 16, + marginBottom: 2, + }, -suggestionText: { - fontSize: 14, - color: '#003340', -}, + suggestionCategory: { + fontSize: 10, + color: "rgba(255, 255, 255, 0.6)", + backgroundColor: "rgba(255, 255, 255, 0.1)", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + overflow: "hidden", + }, - textInput: { + // Input Bar + inputBar: { + paddingTop: 12, + paddingHorizontal: 16, + backgroundColor: "#003340", + borderTopWidth: 1, + borderTopColor: "rgba(255, 255, 255, 0.1)", + }, + + inputContainer: { + flexDirection: "row", + alignItems: "flex-end", + gap: 8, + minHeight: 44, + }, + + suggestionButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: "rgba(255, 255, 255, 0.1)", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.15)", + }, + + suggestionButtonActive: { + backgroundColor: "rgba(16, 185, 129, 0.3)", + borderColor: "rgba(16, 185, 129, 0.5)", + transform: [{ scale: 1.05 }], + }, + + suggestionButtonIcon: { + fontSize: 18, + }, + + textInputContainer: { flex: 1, - backgroundColor: '#3D5B66', - borderRadius: 10, - paddingHorizontal: 15, - paddingVertical: 10, - color: 'white', - fontSize: 15, - marginRight: 8, + backgroundColor: "rgba(255, 255, 255, 0.1)", + borderRadius: 22, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.15)", + minHeight: 44, + maxHeight: 120, + justifyContent: "center", }, - // searchButton: { - // padding: 8, - // }, - searchText: { - fontSize: 20, - color: 'white', + + textInput: { + paddingHorizontal: 16, + paddingVertical: 12, + color: "#FFFFFF", + fontSize: INPUT_FONT_SIZE, + lineHeight: 20, + letterSpacing: 0.2, + minHeight: 44, + }, + + sendButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: "rgba(255, 255, 255, 0.1)", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.15)", + }, + + sendButtonActive: { + backgroundColor: "rgba(182, 137, 186, 0.3)", + borderColor: "#fb9dd28f", + shadowColor: "#fb9dd2ff", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 4, }, }); diff --git a/src/screens/Guide/GuideLevel1.js b/src/screens/Guide/GuideLevel1.js index 6aabfd2..7cd95ee 100644 --- a/src/screens/Guide/GuideLevel1.js +++ b/src/screens/Guide/GuideLevel1.js @@ -1,5 +1,5 @@ -// GuideLevel1.js -import React, { useState, useCallback } from 'react'; +// GuideLevel1.js - Updated Header Row +import React, { useState } from 'react'; import { View, ScrollView, @@ -10,6 +10,7 @@ import { Alert, } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // SVG 아이콘 import import CheckIcon from '../../assets/icons/studycheck.svg'; @@ -21,31 +22,39 @@ import { getNewAccessToken } from '../../utils/token'; const GuideLevel1 = () => { const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [loading, setLoading] = useState(true); const [contentProgress, setContentProgress] = useState({}); + const [error, setError] = useState(null); const fetchProgress = async () => { setLoading(true); - const accessToken = await getNewAccessToken(navigation); - if (!accessToken) { - Alert.alert('인증 오류', '토큰이 만료되었습니다. 다시 로그인해주세요.'); - navigation.navigate('Login'); - return; - } + setError(null); try { - const res = await fetch( - `${API_BASE_URL}progress/level/1/content/`, - { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); + const accessToken = await getNewAccessToken(navigation); + if (!accessToken) { + Alert.alert('인증 오류', '토큰이 만료되었습니다. 다시 로그인해주세요.'); + navigation.navigate('Login'); + return; + } + + const res = await fetch(`${API_BASE_URL}progress/level/1/content/`, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + if (!res.ok) throw new Error(`Level 1 content fetch failed: ${res.status}`); + const data = await res.json(); - setContentProgress(data.content_progress); + setContentProgress(data?.content_progress || {}); } catch (err) { - console.error(err); + console.error('Error fetching progress:', err); + setError(err.message); Alert.alert('데이터 오류', '진행도 정보를 불러오는 중 오류가 발생했습니다.'); } finally { setLoading(false); @@ -53,67 +62,80 @@ const GuideLevel1 = () => { }; useFocusEffect( - useCallback(() => { + React.useCallback(() => { fetchProgress(); }, []) ); + const handleRetry = () => fetchProgress(); + + const handlePress = (id) => { + navigation.navigate('StudyScreen', { level: 1, contentIndex: id }); + }; + if (loading) { return ( - + ); } + if (error) { + return ( + + 네트워크 오류 + + 다시 시도 + + + ); + } + 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; + const firstIncomplete = entries.find((e) => !e.done)?.id; return ( - - navigation.goBack()} - style={styles.backButton} - accessibilityLabel="뒤로가기" - > - {'<'} - - - 1단계 - - + + {/* 🔹 Header Row */} + + navigation.goBack()} + style={styles.backButton} + accessibilityLabel="뒤로가기" + > + {'<'} + + + 1단계 + + + {entries.map(({ id, done }, idx) => { - const isChest = !done && id === firstIncomplete; + const isCurrent = !done && id === firstIncomplete; + let IconComp; if (done) IconComp = ; - else if (isChest) IconComp = ; + else if (isCurrent) IconComp = ; else IconComp = ; - const clickable = done || isChest; - const handlePress = () => - navigation.navigate('StudyScreen', { level: 1, contentIndex: id }); + const isClickable = done || isCurrent; return ( - {clickable ? ( - + {isClickable ? ( + handlePress(id)} activeOpacity={0.7} style={styles.iconTouchable}> {IconComp} ) : ( - IconComp + {IconComp} )} {`챕터 1-${id}`} @@ -128,19 +150,25 @@ const GuideLevel1 = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#6DC0D4', + backgroundColor: '#6DC0D4', // 기존 Level1 톤 유지 paddingHorizontal: 30, - paddingTop: 60, }, center: { justifyContent: 'center', alignItems: 'center', }, + // 🔹 Header Row 스타일 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', // 가운데 기준 + marginTop: 20, + marginBottom: 20, + }, backButton: { - position: 'absolute', - top: 50, - left: 20, - zIndex: 10, + position: 'absolute', // 왼쪽에 고정 + left: 0, + padding: 10, // 터치 영역 확대 }, backText: { fontSize: 36, @@ -150,16 +178,14 @@ const styles = StyleSheet.create({ fontSize: 24, fontWeight: 'bold', color: '#FFFFFF', - alignSelf: 'center', - marginBottom: 20, }, scrollView: { - marginTop: 20, paddingBottom: 60, + paddingTop: 20, }, stepContainer: { width: '100%', - marginVertical: 16, + marginVertical: 20, flexDirection: 'row', alignItems: 'flex-start', }, @@ -174,15 +200,41 @@ const styles = StyleSheet.create({ stepBox: { alignItems: 'center', }, + iconTouchable: { + padding: 10, + }, + iconLocked: { + padding: 10, + opacity: 0.6, + }, chapterLabel: { - marginTop: 8, - paddingHorizontal: 10, - paddingVertical: 4, + marginTop: 12, + paddingHorizontal: 12, + paddingVertical: 6, backgroundColor: '#FFFFFF40', borderRadius: 12, fontSize: 14, fontWeight: '600', color: '#003340A0', + textAlign: 'center', + minWidth: 80, + }, + errorText: { + color: '#ffffff', + fontSize: 18, + textAlign: 'center', + marginBottom: 20, + }, + retryButton: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 20, + }, + retryButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', }, }); diff --git a/src/screens/Guide/GuideLevel2.js b/src/screens/Guide/GuideLevel2.js index ae5e0ca..c16f838 100644 --- a/src/screens/Guide/GuideLevel2.js +++ b/src/screens/Guide/GuideLevel2.js @@ -1,5 +1,5 @@ -// GuideLevel2.js -import React, { useEffect, useState } from 'react'; +// GuideLevel2.js - Updated Header Row (consistent with Level1) +import React, { useState } from 'react'; import { View, ScrollView, @@ -9,7 +9,8 @@ import { ActivityIndicator, Alert, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // SVG 아이콘 import import CheckIcon from '../../assets/icons/studycheck.svg'; @@ -21,12 +22,17 @@ import { getNewAccessToken } from '../../utils/token'; const GuideLevel2 = () => { const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [loading, setLoading] = useState(true); const [contentProgress, setContentProgress] = useState({}); + const [error, setError] = useState(null); + + const fetchProgress = async () => { + setLoading(true); + setError(null); - useEffect(() => { - const fetchProgress = async () => { - setLoading(true); + try { const accessToken = await getNewAccessToken(navigation); if (!accessToken) { Alert.alert('인증 오류', '토큰이 만료되었습니다. 다시 로그인해주세요.'); @@ -34,92 +40,110 @@ const GuideLevel2 = () => { return; } - try { - const res = await fetch( - `${API_BASE_URL}progress/level/2/content/`, - { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); - if (!res.ok) throw new Error(`Level 2 content fetch failed: ${res.status}`); - const data = await res.json(); - setContentProgress(data.content_progress); - } catch (err) { - console.error(err); - Alert.alert('데이터 오류', '진행도 정보를 불러오는 중 오류가 발생했습니다.'); - } finally { - setLoading(false); - } - }; + const res = await fetch(`${API_BASE_URL}progress/level/2/content/`, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) throw new Error(`Level 2 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 { + setLoading(false); + } + }; + + useFocusEffect( + React.useCallback(() => { + fetchProgress(); + }, []) + ); - fetchProgress(); - }, [navigation]); + const handleRetry = () => fetchProgress(); + + const handleChapterPress = (contentIndex) => { + navigation.navigate('StudyScreen', { + level: 2, + contentIndex, + }); + }; if (loading) { return ( - + ); } + if (error) { + return ( + + 네트워크 오류 + + 다시 시도 + + + ); + } + + // 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; + const firstIncomplete = entries.find((e) => !e.done)?.id; return ( - - navigation.goBack()} - style={styles.backButton} - accessibilityLabel="뒤로가기" - > - {'<'} - - - 2단계 - - + + {/* 🔹 Header Row - Level1과 동일한 구조 */} + + navigation.goBack()} + style={styles.backButton} + accessibilityLabel="뒤로가기" + > + {'<'} + + + 2단계 + + + {entries.map(({ id, done }, idx) => { - const isChest = !done && id === firstIncomplete; + const isCurrent = !done && id === firstIncomplete; + let IconComp; - if (done) { - IconComp = ; - } else if (isChest) { - IconComp = ; - } else { - IconComp = ; - } - - const clickable = done || isChest; - const handlePress = () => { - navigation.navigate('StudyScreen', { - level: 2, - contentIndex: id, - }); - }; + if (done) IconComp = ; + else if (isCurrent) IconComp = ; + else IconComp = ; + + const isClickable = done || isCurrent; return ( - {clickable ? ( - + {isClickable ? ( + handleChapterPress(id)} + activeOpacity={0.7} + style={styles.iconTouchable} + > {IconComp} ) : ( - IconComp + {IconComp} )} {`챕터 2-${id}`} @@ -134,19 +158,25 @@ const GuideLevel2 = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#2594B0', + backgroundColor: '#2594B0', // 기존 Level2 톤 유지 paddingHorizontal: 30, - paddingTop: 60, }, center: { justifyContent: 'center', alignItems: 'center', }, + // 🔹 Header Row 스타일 - Level1과 동일 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', // 가운데 기준 + marginTop: 20, + marginBottom: 20, + }, backButton: { - position: 'absolute', - top: 50, - left: 20, - zIndex: 10, + position: 'absolute', // 왼쪽에 고정 + left: 0, + padding: 10, // 터치 영역 확대 }, backText: { fontSize: 36, @@ -156,16 +186,14 @@ const styles = StyleSheet.create({ fontSize: 24, fontWeight: 'bold', color: '#FFFFFF', - alignSelf: 'center', - marginBottom: 20, }, scrollView: { - marginTop: 20, paddingBottom: 60, + paddingTop: 20, }, stepContainer: { width: '100%', - marginVertical: 16, + marginVertical: 20, flexDirection: 'row', alignItems: 'flex-start', }, @@ -180,16 +208,42 @@ const styles = StyleSheet.create({ stepBox: { alignItems: 'center', }, + iconTouchable: { + padding: 10, + }, + iconLocked: { + padding: 10, + opacity: 0.6, + }, chapterLabel: { - marginTop: 8, - paddingHorizontal: 10, - paddingVertical: 4, + marginTop: 12, + paddingHorizontal: 12, + paddingVertical: 6, backgroundColor: '#FFFFFF40', borderRadius: 12, fontSize: 14, fontWeight: '600', color: '#003340A0', + textAlign: 'center', + minWidth: 80, + }, + errorText: { + color: '#ffffff', + fontSize: 18, + textAlign: 'center', + marginBottom: 20, + }, + retryButton: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 20, + }, + retryButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', }, }); -export default GuideLevel2; +export default GuideLevel2; \ No newline at end of file diff --git a/src/screens/Guide/GuideLevel3.js b/src/screens/Guide/GuideLevel3.js index a6a7ee4..7cc7f56 100644 --- a/src/screens/Guide/GuideLevel3.js +++ b/src/screens/Guide/GuideLevel3.js @@ -1,4 +1,4 @@ -// GuideLevel3.js +// GuideLevel3.js - Updated Header Row (consistent with Level1) import React, { useEffect, useState } from 'react'; import { View, @@ -8,8 +8,10 @@ import { TouchableOpacity, ActivityIndicator, Alert, + Dimensions, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // SVG 아이콘 import import CheckIcon from '../../assets/icons/studycheck.svg'; @@ -19,14 +21,20 @@ import StudyingIcon from '../../assets/icons/studying.svg'; import { API_BASE_URL } from '../../utils/apiConfig'; import { getNewAccessToken } from '../../utils/token'; +const { width: screenWidth } = Dimensions.get('window'); + const GuideLevel3 = () => { const navigation = useNavigation(); + const insets = useSafeAreaInsets(); const [loading, setLoading] = useState(true); const [contentProgress, setContentProgress] = useState({}); + const [error, setError] = useState(null); - useEffect(() => { - const fetchProgress = async () => { - setLoading(true); + const fetchProgress = async () => { + setLoading(true); + setError(null); + + try { const accessToken = await getNewAccessToken(navigation); if (!accessToken) { Alert.alert('인증 오류', '토큰이 만료되었습니다. 다시 로그인해주세요.'); @@ -34,36 +42,68 @@ const GuideLevel3 = () => { return; } - try { - const res = await fetch( - `${API_BASE_URL}progress/level/3/content/`, - { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); - 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(err); - Alert.alert('데이터 오류', '진행도 정보를 불러오는 중 오류가 발생했습니다.'); - } finally { - setLoading(false); + const res = await fetch( + `${API_BASE_URL}progress/level/3/content/`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + 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 { + setLoading(false); + } + }; + + useFocusEffect( + React.useCallback(() => { + fetchProgress(); + }, []) + ); + const handleRetry = () => { fetchProgress(); - }, [navigation]); + }; + + const handleChapterPress = (contentIndex) => { + navigation.navigate('StudyScreen', { + level: 3, + contentIndex: contentIndex, + }); + }; if (loading) { return ( - + ); } + if (error) { + return ( + + 네트워크 오류 + + 다시 시도 + + + ); + } + // progress 객체를 [ { id, done }, ... ] 형태로 변환 & 정렬 const entries = Object.entries(contentProgress) .map(([key, done]) => ({ id: Number(key), done })) @@ -72,39 +112,37 @@ const GuideLevel3 = () => { const firstIncomplete = entries.find(e => !e.done)?.id; return ( - - navigation.goBack()} - style={styles.backButton} - accessibilityLabel="뒤로가기" - > - {'<'} - + + {/* 🔹 Header Row - Level1과 동일한 구조 */} + + navigation.goBack()} + style={styles.backButton} + accessibilityLabel="뒤로가기" + > + {'<'} + - 3단계 + 3단계 + {entries.map(({ id, done }, idx) => { - const isChest = !done && id === firstIncomplete; - let IconComp; + const isCurrentChapter = !done && id === firstIncomplete; + let IconComponent; + if (done) { - IconComp = ; - } else if (isChest) { - IconComp = ; + IconComponent = ; + } else if (isCurrentChapter) { + IconComponent = ; } else { - IconComp = ; + IconComponent = ; } - const clickable = done || isChest; - const handlePress = () => { - navigation.navigate('StudyScreen', { - level: 3, - contentIndex: id, - }); - }; + const isClickable = done || isCurrentChapter; return ( { ]} > - {clickable ? ( - - {IconComp} + {isClickable ? ( + handleChapterPress(id)} + activeOpacity={0.7} + style={styles.iconTouchable} + > + {IconComponent} ) : ( - IconComp + + {IconComponent} + )} {`챕터 3-${id}`} @@ -137,17 +181,25 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#037F9F', paddingHorizontal: 30, - paddingTop: 60, }, + center: { justifyContent: 'center', alignItems: 'center', }, + + // 🔹 Header Row 스타일 - Level1과 동일 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', // 가운데 기준 + marginTop: 20, + marginBottom: 20, + }, backButton: { - position: 'absolute', - top: 50, - left: 20, - zIndex: 10, + position: 'absolute', // 왼쪽에 고정 + left: 0, + padding: 10, // 터치 영역 확대 }, backText: { fontSize: 36, @@ -157,40 +209,73 @@ const styles = StyleSheet.create({ fontSize: 24, fontWeight: 'bold', color: '#FFFFFF', - alignSelf: 'center', - marginBottom: 20, }, + scrollView: { - marginTop: 20, paddingBottom: 60, + paddingTop: 20, }, + stepContainer: { width: '100%', - marginVertical: 16, + marginVertical: 20, flexDirection: 'row', alignItems: 'flex-start', }, + left: { justifyContent: 'flex-start', paddingLeft: '15%', }, + right: { justifyContent: 'flex-end', paddingRight: '15%', }, + stepBox: { alignItems: 'center', }, + + iconTouchable: { + padding: 10, // 터치 영역 확대 + }, + + iconLocked: { + padding: 10, + opacity: 0.6, + }, + chapterLabel: { - marginTop: 8, - paddingHorizontal: 10, - paddingVertical: 4, + marginTop: 12, + paddingHorizontal: 12, + paddingVertical: 6, backgroundColor: '#FFFFFF40', borderRadius: 12, fontSize: 14, fontWeight: '600', color: '#003340A0', + textAlign: 'center', + minWidth: 80, + }, + + errorText: { + color: '#ffffff', + fontSize: 18, + textAlign: 'center', + marginBottom: 20, + }, + retryButton: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 20, + }, + retryButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', }, }); -export default GuideLevel3; +export default GuideLevel3; \ No newline at end of file diff --git a/src/screens/Guide/GuideScreen.js b/src/screens/Guide/GuideScreen.js index d2c6148..f91405b 100644 --- a/src/screens/Guide/GuideScreen.js +++ b/src/screens/Guide/GuideScreen.js @@ -11,12 +11,15 @@ import { Image, } from "react-native"; import { useNavigation, useFocusEffect } from "@react-navigation/native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import Icon from "react-native-vector-icons/Feather"; + import LearningProgressBar from "../../components/LearningProgressBar"; import InspectIcon from "../../assets/icons/stock-inspect.svg"; import ResultIcon from "../../assets/icons/stock-result.svg"; import LockIcon from "../../assets/icons/lock.svg"; -// import QuestionIcon from "../../assets/icons/question.png"; + import { API_BASE_URL } from "../../utils/apiConfig"; import { getNewAccessToken } from "../../utils/token"; @@ -24,11 +27,33 @@ const LEVELS = [1, 2, 3]; const GuideScreen = () => { const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const tabBarHeight = useBottomTabBarHeight(); + + // 상단 여백: 기기 safe-area + 추가 마진 + const topGutter = Math.max(insets.top, 0) + 24; + const [progressMap, setProgressMap] = useState({}); const [loading, setLoading] = useState(true); + // ✅ 튜토리얼: 헤더 우측 아이콘으로 이동 useFocusEffect( useCallback(() => { + navigation.setOptions({ + headerTitle: "학습 가이드", + headerStyle: { backgroundColor: "#003340" }, + headerTintColor: "#c6d4e1", + headerRight: () => ( + navigation.navigate("TutorialScreen", { allowSkip: true })} + style={{ paddingHorizontal: 12, paddingVertical: 6 }} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ), + }); + const loadAllProgress = async () => { setLoading(true); const accessToken = await getNewAccessToken(navigation); @@ -41,25 +66,17 @@ const GuideScreen = () => { 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}`); - } + 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); } catch (err) { console.error(err); - Alert.alert( - "데이터 오류", - "진행도 정보를 불러오는 중 오류가 발생했습니다." - ); + Alert.alert("데이터 오류", "진행도 정보를 불러오는 중 오류가 발생했습니다."); } finally { setLoading(false); } @@ -71,14 +88,14 @@ const GuideScreen = () => { if (loading) { return ( - + ); } const ClearButton = ({ label, onPress }) => ( - + {label} @@ -87,7 +104,7 @@ const GuideScreen = () => { ); const UnClearButton = ({ onPress, children }) => ( - + {children} @@ -96,208 +113,278 @@ const GuideScreen = () => { ); return ( - - 🧠 투자 유형 검사하기 - - navigation.navigate("TypeExam")} - > - - 유형 검사하기 - + + + {/* 인라인 튜토리얼 카드 (헤더 아이콘 보완) */} navigation.navigate("TypeResult")} + activeOpacity={0.85} + onPress={() => navigation.navigate("TutorialScreen", { allowSkip: true })} + style={styles.tutorialCard} > - - 유형 결과 확인하기 + + + + + 튜토리얼 빠르게 보기 + 핵심 기능을 1분 컷으로 훑어보기 + + - - - ✏️ 주식 초보를 위한 학습가이드 - - {LEVELS.map((levelId) => { - const data = progressMap[levelId] || { - completed: 0, - total: 0, - is_level_completed: false, - progress_ratio: "0/0", - }; - - // 1단계는 항상 잠금 해제. - // 그 외에는 이전 단계 완료 여부로 잠금 상태 결정 - const prevComplete = - levelId === 1 || progressMap[levelId - 1]?.is_level_completed; - const showLockIcon = !prevComplete; - - const label = `${levelId}단계`; - const onPress = () => navigation.navigate(`GuideLevel${levelId}`); - - return ( - - {data.is_level_completed ? ( - - ) : ( - - - {label} - {showLockIcon && ( - - )} + 🧠 투자 유형 검사하기 + + + navigation.navigate("TypeExam")} + activeOpacity={0.9} + > + + + + + + 유형 검사하기 + 간단한 질문으로 투자 성향 파악 + + - - )} - + + + navigation.navigate("TypeResult")} + activeOpacity={0.9} + > + + + + + + 결과 확인하기 + 나의 투자 유형과 추천 전략 + + - ); - })} + + + + + + ✏️ 주식 초보를 위한 학습가이드 + + + {LEVELS.map((levelId) => { + 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 ( + + {data.is_level_completed ? ( + + ) : ( + + + {label} + {showLockIcon && } + + + )} + + + + ); + })} + - - navigation.navigate("TutorialScreen", { allowSkip: true })} - activeOpacity={0.7} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - style={styles.fabImageWrapper} // (옵션) 그림자만 주는 래퍼 - > - - - 튜토리얼 - + + {/* ⛔ FAB 제거: 위치 애매/시야 방해 이슈 해소 */} ); }; const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#003340", - paddingHorizontal: 30, - paddingTop: 60, + container: { flex: 1, backgroundColor: "#003340" }, + + scrollContent: { paddingHorizontal: 20 }, + + center: { justifyContent: "center", alignItems: "center" }, + + // 인라인 튜토리얼 카드 + tutorialCard: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "rgba(255,255,255,0.06)", + borderRadius: 14, + padding: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.08)", + marginBottom: 16, }, - center: { - justifyContent: "center", + tutorialCardLeft: { + width: 44, + height: 44, + borderRadius: 10, + backgroundColor: "rgba(255,255,255,0.08)", alignItems: "center", + justifyContent: "center", + }, + tutorialTitle: { + color: "#FFFFFF", + fontSize: 14.5, + fontWeight: "600", + letterSpacing: 0.2, }, + tutorialDesc: { + marginTop: 2, + color: "rgba(255,255,255,0.7)", + fontSize: 12.5, + }, + title: { - color: "#EEEEEE", - fontSize: 18, - marginBottom: 20, - marginLeft: 15, + color: "#c6d4e1ff", + fontSize: 17, + marginBottom: 15, + fontWeight: "500", + textAlign: "left", + marginLeft: 4, marginTop: 5, - fontWeight: "600", + letterSpacing: 0.2, }, + buttonContainer: { - flexDirection: "row", - justifyContent: "space-between", + gap: 12, marginBottom: 10, }, + examButton: { - flex: 1, - aspectRatio: 1, - backgroundColor: "#6EE69EE0", - borderRadius: 20, - marginHorizontal: 10, - alignItems: "center", - justifyContent: "center", - padding: 16, + backgroundColor: "rgba(110, 230, 158, 0.15)", + borderRadius: 16, + padding: 20, + borderWidth: 1, + borderColor: "rgba(110, 230, 158, 0.3)", + shadowColor: "rgba(110, 230, 158, 0.4)", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, }, + resultButton: { - flex: 1, - aspectRatio: 1, - backgroundColor: "#F074BAE0", - borderRadius: 20, - marginHorizontal: 10, - alignItems: "center", - justifyContent: "center", - padding: 16, - }, - buttonText: { - fontFamily: "System", - color: "#EFF1F5", - fontSize: 15, - fontWeight: "600", - marginTop: 10, + backgroundColor: "rgba(240, 116, 186, 0.15)", + borderRadius: 16, + padding: 20, + borderWidth: 1, + borderColor: "rgba(240, 116, 186, 0.3)", + shadowColor: "rgba(240, 116, 186, 0.4)", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, }, - divider: { - height: 1, - backgroundColor: "#4A5A60", - marginVertical: 20, - }, - menuContainer: { - paddingBottom: 30, - }, - menuRow: { + + examButtonContent: { flexDirection: "row", - justifyContent: "space-between", alignItems: "center", + justifyContent: "space-between", }, - clearButton: { - backgroundColor: "#D4DDEF60", - padding: 15, - borderRadius: 15, - marginVertical: 5, - marginHorizontal: 10, - }, - unclearButton: { - backgroundColor: "#D4DDEF20", - padding: 15, - borderRadius: 15, - marginVertical: 5, - marginHorizontal: 10, + + examIconContainer: { + width: 80, + height: 75, + backgroundColor: "rgba(110, 230, 158, 0.2)", + borderRadius: 16, + justifyContent: "flex-end", + alignItems: "center", }, - labelWithIcon: { - flexDirection: "row", + + resultIconContainer: { + width: 80, + height: 75, + backgroundColor: "rgba(240, 116, 186, 0.2)", + borderRadius: 16, + justifyContent: "flex-end", alignItems: "center", }, - lockIcon: { - marginLeft: 6, - marginTop: 1, + + examTextContainer: { + flex: 1, + marginLeft: 16, + marginRight: 12, }, - menuText: { - fontSize: 16, - color: "white", - fontWeight: "bold", + + examButtonTitle: { + color: "#ffffff", + fontSize: 17, + fontWeight: "600", + marginBottom: 4, + letterSpacing: 0.3, }, - fabContainer: { - position: "absolute", - right: 30, - bottom: 100, - alignItems: "center", + + examButtonSubtitle: { + color: "rgba(255, 255, 255, 0.7)", + fontSize: 13, + fontWeight: "400", + lineHeight: 18, }, - // (선택) 이미지에 살짝 그림자 주고 싶으면 사용, 아니면 삭제해도 됨 - // fabImageWrapper: { - // shadowColor: "#000", - // shadowOffset: { width: 0, height: 2 }, - // shadowOpacity: 0.2, - // shadowRadius: 3, - // elevation: 3, - // }, - // PNG 자체가 버튼이므로 배경/테두리 없음 - fabImage: { - width: 56, // 원본 크기를 쓰고 싶으면 이 두 줄 지워도 됩니다 - height: 56, + + divider: { + height: 1, + backgroundColor: "rgba(255, 255, 255, 0.1)", + marginVertical: 25, + }, + + menuContainer: { paddingBottom: 10 }, + levelBlock: { marginBottom: 8 }, + menuRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + + clearButton: { + backgroundColor: "rgba(255, 255, 255, 0.12)", + borderRadius: 12, + paddingVertical: 16, + paddingHorizontal: 18, + marginVertical: 6, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.14)", }, - fabLabel: { - marginTop: 6, - fontSize: 12, - color: "#EEEEEE", + unclearButton: { + backgroundColor: "rgba(255, 255, 255, 0.06)", + borderRadius: 12, + paddingVertical: 16, + paddingHorizontal: 18, + marginVertical: 6, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.08)", }, + + labelWithIcon: { flexDirection: "row", alignItems: "center" }, + lockIcon: { marginLeft: 6, marginTop: 1 }, + + menuText: { fontSize: 17, color: "#FFFFFF", fontWeight: "500", letterSpacing: 0.2 }, }); export default GuideScreen; diff --git a/src/screens/Guide/StudyScreen.js b/src/screens/Guide/StudyScreen.js index 80e9272..1f19af9 100644 --- a/src/screens/Guide/StudyScreen.js +++ b/src/screens/Guide/StudyScreen.js @@ -1,22 +1,29 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState, useCallback } from "react"; import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, - Animated, StyleSheet, Alert, + Dimensions, + Share, + Modal, } from "react-native"; -import { useRoute, useNavigation } from "@react-navigation/native"; +import { useRoute, useNavigation, useFocusEffect } from "@react-navigation/native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Icon from "react-native-vector-icons/Feather"; import Markdown from "react-native-markdown-display"; import { API_BASE_URL } from "../../utils/apiConfig"; import { getNewAccessToken } from "../../utils/token"; +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + const StudyScreen = () => { const { level, contentIndex } = useRoute().params; const navigation = useNavigation(); + const insets = useSafeAreaInsets(); // map level & contentIndex → advanced-guide id const guideApiId = @@ -29,217 +36,662 @@ const StudyScreen = () => { const [content, setContent] = useState(""); const [loading, setLoading] = useState(true); const [completing, setCompleting] = useState(false); + const [error, setError] = useState(null); + const [showMenu, setShowMenu] = useState(false); // 메뉴 모달 상태 + const [fontSize, setFontSize] = useState(16); // 글꼴 크기 상태 const [progressIndex, setProgressIndex] = useState(0); - const [showButton, setShowButton] = useState(true); - const buttonAnim = useRef(new Animated.Value(1)).current; - const scrollOffset = useRef(0); + const scrollViewRef = useRef(null); // fetch guide content - useEffect(() => { - const fetchGuide = async () => { + const fetchGuide = async () => { + setLoading(true); + setError(null); + + try { const accessToken = await getNewAccessToken(navigation); if (!accessToken) { Alert.alert("인증 오류", "토큰이 만료되었습니다. 다시 로그인해주세요."); navigation.navigate("Login"); return; } - try { - const res = await fetch( - `${API_BASE_URL}api/advanced-guides/${guideApiId}/`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); - if (!res.ok) throw new Error(`Failed to load (${res.status})`); - const data = await res.json(); - setContent(data.content); - } catch (err) { - console.error(err); - setContent("[불러오기 실패]"); - } finally { - setLoading(false); + + const res = await fetch( + `${API_BASE_URL}api/advanced-guides/${guideApiId}/`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + 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 { + setLoading(false); + } + }; + + useEffect(() => { fetchGuide(); - }, [guideApiId, navigation]); + }, [guideApiId]); - // handle scroll for progress bar & button - const handleScroll = (e) => { + // handle scroll for progress bar only + const handleScroll = useCallback((e) => { const y = e.nativeEvent.contentOffset.y; const scrollH = e.nativeEvent.contentSize.height - e.nativeEvent.layoutMeasurement.height; - const pct = scrollH > 0 ? y / scrollH : 0; - setProgressIndex(Math.min(5, Math.floor(pct * 5))); - - const dir = y > scrollOffset.current ? "down" : "up"; - if (dir === "down" && showButton) { - setShowButton(false); - Animated.timing(buttonAnim, { toValue: 0, duration: 200, useNativeDriver: true }).start(); - } else if (dir === "up" && !showButton) { - setShowButton(true); - Animated.timing(buttonAnim, { toValue: 1, duration: 200, useNativeDriver: true }).start(); - } - scrollOffset.current = y; - }; + const pct = scrollH > 0 ? Math.max(0, Math.min(1, y / scrollH)) : 0; + setProgressIndex(Math.min(10, Math.floor(pct * 10))); + }, []); // mark complete const handleComplete = async () => { + if (completing) return; + setCompleting(true); - const accessToken = await getNewAccessToken(navigation); - if (!accessToken) { - Alert.alert("인증 오류", "토큰이 만료되었습니다. 다시 로그인해주세요."); - navigation.navigate("Login"); - return; - } try { + const accessToken = await getNewAccessToken(navigation); + if (!accessToken) { + Alert.alert("인증 오류", "토큰이 만료되었습니다. 다시 로그인해주세요."); + navigation.navigate("Login"); + return; + } + const res = await fetch( `${API_BASE_URL}progress/complete/${level}/${contentIndex}/`, { method: "POST", - headers: { Authorization: `Bearer ${accessToken}` }, + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, } ); - if (!res.ok) throw new Error(`Complete failed (${res.status})`); - // 돌아가서 GuideLevel 화면이 리프레시되도록 - navigation.goBack(); + + if (!res.ok) { + throw new Error(`Complete failed (${res.status})`); + } + + // 성공 피드백 + Alert.alert( + "학습 완료!", + "챕터를 성공적으로 완료했습니다.", + [ + { + text: "확인", + onPress: () => navigation.goBack(), + } + ] + ); } catch (err) { - console.error(err); - Alert.alert("오류", "완료 처리 중 문제가 발생했습니다."); + console.error("Error completing:", err); + Alert.alert("오류", "완료 처리 중 문제가 발생했습니다. 다시 시도해주세요."); } finally { setCompleting(false); } }; + const handleRetry = () => { + fetchGuide(); + }; + + // 메뉴 기능들 + const handleShare = async () => { + try { + await Share.share({ + message: `챕터 ${level}-${contentIndex} 학습 콘텐츠를 공유합니다!`, + title: '학습 콘텐츠 공유', + }); + } catch (error) { + console.error('공유 오류:', error); + } + setShowMenu(false); + }; + + const handleBookmark = () => { + Alert.alert("북마크", "이 챕터를 북마크에 추가했습니다!", [ + { text: "확인", onPress: () => setShowMenu(false) } + ]); + }; + + const handleScrollToTop = () => { + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + setShowMenu(false); + }; + + const handleFontSizeChange = (size) => { + setFontSize(size); + setShowMenu(false); + }; + + const handleReport = () => { + Alert.alert( + "콘텐츠 신고", + "이 콘텐츠에 문제가 있나요?", + [ + { text: "취소", style: "cancel" }, + { text: "신고하기", onPress: () => { + Alert.alert("신고 완료", "신고가 접수되었습니다. 검토 후 조치하겠습니다."); + setShowMenu(false); + }} + ] + ); + }; + if (loading) { return ( - + + 학습 콘텐츠를 불러오는 중... + + ); + } + + if (error) { + return ( + + + 콘텐츠를 불러올 수 없습니다 + + 다시 시도 + ); } return ( - - {/* header */} + + {/* Header */} - navigation.goBack()} style={styles.backButton}> - {"<"} + navigation.goBack()} + style={styles.backButton} + activeOpacity={0.7} + > + + - {`${level}-${contentIndex}`} + 챕터 {level}-{contentIndex} 학습 콘텐츠 - + + + setShowMenu(true)} + > + + + - {/* progress bar */} - + {/* Progress Bar */} + + + {Math.round((progressIndex / 10) * 100)}% + - {/* body */} + {/* Content */} - {content} + + {content} + + + {/* Complete Button - 페이지 하단에 고정 */} + + + {completing ? ( + + + 처리 중... + + ) : ( + + + 학습을 완료했어요 + + )} + + - {/* complete button */} - setShowMenu(false)} > setShowMenu(false)} > - {completing ? ( - - ) : ( - 학습을 완료했어요 - )} + + {/* + + 북마크 추가 + */} + + + + 공유하기 + + + + + 맨 위로 + + + + + + 글꼴 크기 + + handleFontSizeChange(14)} + > + 작게 + + handleFontSizeChange(16)} + > + 보통 + + handleFontSizeChange(18)} + > + 크게 + + + + + + + + + 문제 신고 + + - + ); }; const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#F7F9FA" }, - center: { flex: 1, justifyContent: "center", alignItems: "center" }, + container: { + flex: 1, + backgroundColor: "#F8FAFB" + }, + + center: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 20, + }, + + loadingText: { + color: "#666", + fontSize: 16, + marginTop: 16, + textAlign: "center", + }, + + errorText: { + color: "#666", + fontSize: 18, + textAlign: "center", + marginVertical: 16, + }, + + retryButton: { + backgroundColor: "#00AACC", + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 24, + marginTop: 16, + }, + + retryButtonText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + }, + header: { flexDirection: "row", alignItems: "center", - paddingTop: 50, paddingHorizontal: 20, - paddingBottom: 10, - backgroundColor: "#E0F4F9", + paddingVertical: 16, + backgroundColor: "#E8F4F8", + borderBottomWidth: 1, + borderBottomColor: "rgba(0, 51, 64, 0.1)", + }, + + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "rgba(255, 255, 255, 0.8)", + alignItems: "center", + justifyContent: "center", + marginRight: 12, + }, + + headerTitleContainer: { + flex: 1, + alignItems: "center" + }, + + chapterNumber: { + fontSize: 14, + color: "#666", + fontWeight: "500", + }, + + headerTitle: { + fontSize: 18, + fontWeight: "600", + color: "#003340", + marginTop: 2, + }, + + headerRight: { + width: 40, + alignItems: "center", + }, + + menuButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(255, 255, 255, 0.8)", + alignItems: "center", + justifyContent: "center", + }, + + progressContainer: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: "#fff", }, - backButton: { marginRight: 10 }, - backText: { fontSize: 28, color: "#003340" }, - headerTitleContainer: { flex: 1, alignItems: "center" }, - chapterNumber: { fontSize: 14, color: "#003340" }, - headerTitle: { fontSize: 18, fontWeight: "bold", color: "#003340" }, progressBarContainer: { - height: 6, - width: "80%", - backgroundColor: "#D0DCE0", - borderRadius: 3, - marginTop: 10, - marginBottom: 6, + flex: 1, + height: 8, + backgroundColor: "#E5F3F6", + borderRadius: 4, + marginRight: 12, }, + progressBarFill: { - height: 6, + height: 8, backgroundColor: "#00AACC", - borderRadius: 3, + borderRadius: 4, + minWidth: 8, + }, + + progressText: { + fontSize: 14, + fontWeight: "600", + color: "#00AACC", + minWidth: 40, + textAlign: "right", }, scrollArea: { + flex: 1, + }, + + scrollContent: { + paddingBottom: 20, + }, + + contentContainer: { + backgroundColor: "#fff", + marginHorizontal: 0, + paddingHorizontal: 20, + paddingVertical: 24, + }, + + completeButtonContainer: { paddingHorizontal: 20, - marginTop: 10, - marginBottom: 70, + paddingVertical: 20, + backgroundColor: "#F8FAFB", }, completeButton: { + backgroundColor: "#00AACC", + paddingVertical: 16, + borderRadius: 16, + alignItems: "center", + shadowColor: "#00AACC", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + + completingButton: { + backgroundColor: "#0088AA", + }, + + buttonContent: { + flexDirection: "row", + alignItems: "center", + }, + + buttonText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + marginLeft: 8, + }, + + completingContent: { + flexDirection: "row", + alignItems: "center", + }, + + completingText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + marginLeft: 8, + }, + + // 메뉴 모달 스타일 + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.3)", + }, + + menuContainer: { position: "absolute", - bottom: 20, - left: 20, right: 20, - backgroundColor: "#003340", - paddingVertical: 14, - borderRadius: 10, + backgroundColor: "#fff", + borderRadius: 12, + paddingVertical: 8, + minWidth: 180, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + + menuItem: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + }, + + menuItemText: { + fontSize: 15, + color: "#333", + marginLeft: 12, + fontWeight: "500", + }, + + menuDivider: { + height: 1, + backgroundColor: "#E5E7EB", + marginHorizontal: 12, + marginVertical: 4, + }, + + fontSizeContainer: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + + fontSizeTitle: { + fontSize: 14, + color: "#666", + marginBottom: 8, + fontWeight: "500", + }, + + fontSizeButtons: { + flexDirection: "row", + justifyContent: "space-between", + }, + + fontSizeButton: { + flex: 1, + paddingVertical: 6, alignItems: "center", + marginHorizontal: 2, + borderRadius: 6, + backgroundColor: "#F3F4F6", + }, + + fontSizeButtonActive: { + backgroundColor: "#00AACC", + }, + + fontSizeButtonText: { + fontSize: 13, + color: "#666", + fontWeight: "500", + }, + + fontSizeButtonTextActive: { + color: "#fff", }, - buttonText: { color: "#fff", fontSize: 16, fontWeight: "bold" }, }); const markdownStyles = { - body: { fontSize: 16, lineHeight: 26, color: "#333" }, - heading1: { fontSize: 22, fontWeight: "bold", marginTop: 20, marginBottom: 8 }, - heading2: { fontSize: 20, fontWeight: "bold", marginTop: 18, marginBottom: 6 }, - list_item: { flexDirection: "row", alignItems: "flex-start" }, + body: { + fontSize: 16, + lineHeight: 28, + color: "#333", + fontFamily: "System", + }, + + heading1: { + fontSize: 24, + fontWeight: "700", + marginTop: 24, + marginBottom: 12, + color: "#003340", + }, + + heading2: { + fontSize: 20, + fontWeight: "600", + marginTop: 20, + marginBottom: 10, + color: "#003340", + }, + + heading3: { + fontSize: 18, + fontWeight: "600", + marginTop: 16, + marginBottom: 8, + color: "#003340", + }, + + paragraph: { + marginBottom: 16, + lineHeight: 28, + }, + + list_item: { + flexDirection: "row", + alignItems: "flex-start", + marginBottom: 8, + }, + + bullet_list: { + marginBottom: 16, + }, + + code_inline: { + backgroundColor: "#F5F7FA", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + fontSize: 14, + color: "#00AACC", + }, + + code_block: { + backgroundColor: "#F5F7FA", + padding: 16, + borderRadius: 8, + marginVertical: 12, + }, + + blockquote: { + backgroundColor: "#F8FAFB", + borderLeftWidth: 4, + borderLeftColor: "#00AACC", + paddingLeft: 16, + paddingVertical: 12, + marginVertical: 12, + }, }; -export default StudyScreen; +export default StudyScreen; \ No newline at end of file diff --git a/src/screens/Main/MainScreen.js b/src/screens/Main/MainScreen.js index 17520df..14bafda 100644 --- a/src/screens/Main/MainScreen.js +++ b/src/screens/Main/MainScreen.js @@ -547,11 +547,11 @@ const styles = StyleSheet.create({ }, scrollContent: { paddingHorizontal: 30, - paddingTop: 40, + paddingTop: 10, paddingBottom: 120, // 탭 바 공간 확보 }, searchContainer: { - marginTop: 40, + marginTop: 60, flexDirection: "row", alignItems: "center", marginBottom: 20, diff --git a/src/screens/Main/StockDetail.js b/src/screens/Main/StockDetail.js index c30bf38..caea54f 100644 --- a/src/screens/Main/StockDetail.js +++ b/src/screens/Main/StockDetail.js @@ -113,11 +113,11 @@ const StockDetail = ({ route, navigation }) => { // 3. 데이터 설정 if (priceData.status === "success" && changeData.status === "success") { const changeSign = - changeData.change_status === "up" - ? " ⏶ " - : changeData.change_status === "down" - ? " ⏷ " - : ""; + changeData.change_status === "up" + ? " \u25B2 " + : changeData.change_status === "down" + ? " \u25BC " + : ""; const priceChangeSign = changeData.change_status === "up" diff --git a/src/screens/MyPage/MyPageScreen.js b/src/screens/MyPage/MyPageScreen.js index 3c2eacb..c1b2fed 100644 --- a/src/screens/MyPage/MyPageScreen.js +++ b/src/screens/MyPage/MyPageScreen.js @@ -1,4 +1,6 @@ +// /src/screens/MyPage/MyPageScreen.js import React, { useEffect, useState } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { View, Text, @@ -20,14 +22,16 @@ import { increaseBalance } from "../../utils/point"; import { unregisterPushToken } from "../../services/PushNotificationService"; const MyPageScreen = ({ navigation }) => { - console.log("📌 MyPageScreen 렌더링"); + const insets = useSafeAreaInsets(); + console.log("🔌 MyPageScreen 렌더링"); const [userInfo, setUserInfo] = useState(null); const [loading, setLoading] = useState(true); const [equippedBadges, setEquippedBadges] = useState(["🔥", "🌟", "💯"]); - const [introText, setIntroText] = useState("티끌 모아 태산이긴해!"); const [isEditingIntro, setIsEditingIntro] = useState(false); const [mbtiType, setMbtiType] = useState(null); + const [mbtiAlias, setMbtiAlias] = useState(null); + const [aliasLoading, setAliasLoading] = useState(false); const DEPOSIT_AMOUNT = 100000; @@ -35,33 +39,124 @@ const MyPageScreen = ({ navigation }) => { fetchUserMbtiType(navigation, setMbtiType); }, []); - const MenuButton = ({ label, onPress }) => ( + // MBTI 추천 정보 가져오기 (별명 가져올라구) + const fetchMbtiRecommendations = async () => { + try { + setAliasLoading(true); + console.log("🎯 MBTI 추천 정보 요청 시작"); + + const accessToken = await getNewAccessToken(navigation); + if (!accessToken) { + console.warn("⚠️ 액세스 토큰이 없음"); + return; + } + + const response = await fetch( + `${API_BASE_URL}mbti/result/recommendations/`, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + } + ); + + console.log("📡 MBTI 추천 API 응답 상태:", response.status); + + if (response.ok) { + const data = await response.json(); + console.log("✅ MBTI 추천 데이터:", data); + + if (data.alias) { + setMbtiAlias(data.alias); + console.log("🎭 별명 설정 완료:", data.alias); + } else { + console.warn("⚠️ 응답에 alias가 없음"); + } + } else { + const errorText = await response.text(); + console.warn( + "❌ MBTI 추천 정보 가져오기 실패:", + response.status, + errorText + ); + } + } catch (error) { + console.log("ℹ️ MBTI 추천 정보 가져오기 완료:", error.message || error); +} finally { + setAliasLoading(false); + } + }; + + // 생년월일 포맷팅 + const formatBirthdate = (birthdate) => { + if (!birthdate) return ""; + const date = new Date(birthdate); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}.${month}.${day}`; + }; + + // 이메일 마스킹 + const maskEmail = (email) => { + if (!email) return ""; + const [localPart, domain] = email.split("@"); + if (localPart.length <= 2) return email; + const maskedLocal = + localPart.substring(0, 2) + "*".repeat(localPart.length - 2); + return `${maskedLocal}@${domain}`; + }; + + // 생일까지 D-day 계산 + 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)); + + if (diffDays === 0) return "🎉 생일 축하드려요!"; + if (diffDays === 1) return "🎂 D-1"; + return `🎂 D-${diffDays}`; + }; + + const MenuButton = ({ label, onPress, iconColor = "#ffffff" }) => ( {label} - + ); const handleLogout = async () => { try { - - // 🔔 Push Token 해제 - + console.log("📱 Push Token 해제 시작"); try { - const pushUnregisterSuccess = await unregisterPushToken(); + const pushUnregisterSuccess = await unregisterPushToken(navigation); if (pushUnregisterSuccess) { console.log("✅ Push Token 해제 성공"); } else { - console.warn("Push Token 해제 실패"); + console.warn("⚠️ Push Token 해제 실패 (계속 진행)"); } } catch (pushError) { - console.error("Push Token 해제 중 오류:", pushError); - // Push Token 해제 실패해도 로그아웃됨 - } + console.log("ℹ️ Push Token 해제 건너뜀:", pushError.message || pushError); +} - // 서버에 로그아웃 요청 시도 (실패해도 로컬 정리는 진행) try { const accessToken = await getNewAccessToken(navigation); if (accessToken) { @@ -81,12 +176,10 @@ const MyPageScreen = ({ navigation }) => { } } catch (serverError) { console.warn("⚠️ 서버 로그아웃 요청 중 오류:", serverError); - // 서버 요청이 실패해도 로컬 정리는 계속 진행 } - // 로컬 저장소의 모든 관련 데이터 정리 await Promise.all([ - clearTokens(), // 토큰 정리 + clearTokens(), AsyncStorage.removeItem("userEmail"), AsyncStorage.removeItem("userPassword"), AsyncStorage.removeItem("deviceId"), @@ -99,7 +192,6 @@ const MyPageScreen = ({ navigation }) => { { text: "확인", onPress: () => { - // navigation.reset을 사용하여 이전 화면 스택 정리 navigation.reset({ index: 0, routes: [{ name: "Login" }], @@ -110,14 +202,13 @@ const MyPageScreen = ({ navigation }) => { } catch (err) { console.error("❌ 로그아웃 중 오류:", err); - // 오류가 발생해도 최소한 토큰은 정리하고 로그인 화면으로 이동 try { await Promise.all([ clearTokens(), AsyncStorage.removeItem("userEmail"), AsyncStorage.removeItem("userPassword"), - AsyncStorage.removeItem("deviceId"), - AsyncStorage.removeItem("pushToken") + AsyncStorage.removeItem("deviceId"), + AsyncStorage.removeItem("pushToken"), ]); } catch (cleanupError) { console.error("❌ 로컬 데이터 정리 중 오류:", cleanupError); @@ -137,67 +228,70 @@ const MyPageScreen = ({ navigation }) => { } }; - const handleDeleteAccount = () => { - Alert.alert( - "회원 탈퇴", - "정말 탈퇴하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", - [ - { text: "취소", style: "cancel" }, - { - text: "탈퇴하기", - style: "destructive", - onPress: async () => { +const handleDeleteAccount = () => { + Alert.alert( + "회원 탈퇴", + "정말 탈퇴하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", + [ + { text: "취소", style: "cancel" }, + { + text: "탈퇴하기", + style: "destructive", + onPress: async () => { + try { + console.log("🔱 회원 탈퇴 - Push Token 해제 시작"); + + // Push Token 해제를 조용하게 처리 try { - try { - await unregisterPushToken(); - console.log("회원탈퇴 시 Push Token 해제 완료"); - } catch (pushError) { - console.error("오류:", pushError); - } - - const accessToken = await getNewAccessToken(navigation); - if (!accessToken) { - Alert.alert( - "인증 오류", - "토큰이 만료되었습니다. 다시 로그인해주세요." - ); - navigation.navigate("Login"); - return; - } - - const response = await fetch(`${API_BASE_URL}users/delete/`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (response.ok) { - await Promise.all([ - clearTokens(), - AsyncStorage.removeItem("userEmail"), - AsyncStorage.removeItem("userPassword"), - AsyncStorage.removeItem("deviceId"), - AsyncStorage.removeItem("pushToken"), - ]); - - Alert.alert("탈퇴 완료", "계정이 삭제되었습니다."); - navigation.navigate("Login"); - } else { - const text = await response.text(); - console.error("회원 탈퇴 실패 응답:", text); - Alert.alert("오류", "회원 탈퇴에 실패했습니다."); - } - } catch (err) { - console.error("회원 탈퇴 중 오류:", err); - Alert.alert("오류", "네트워크 오류로 탈퇴에 실패했습니다."); + await unregisterPushToken(navigation); + console.log("✅ 탈퇴 시 Push Token 해제 성공"); + } catch (pushError) { + console.log("ℹ️ 탈퇴 시 Push Token 해제 건너뜀:", pushError.message || "알 수 없는 오류"); } - }, + + const accessToken = await getNewAccessToken(navigation); + if (!accessToken) { + Alert.alert( + "인증 오류", + "토큰이 만료되었습니다. 다시 로그인해주세요." + ); + navigation.navigate("Login"); + return; + } + + const response = await fetch(`${API_BASE_URL}users/delete/`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + await Promise.all([ + clearTokens(), + AsyncStorage.removeItem("userEmail"), + AsyncStorage.removeItem("userPassword"), + AsyncStorage.removeItem("deviceId"), + AsyncStorage.removeItem("pushToken"), + ]); + + Alert.alert("탈퇴 완료", "계정이 삭제되었습니다."); + navigation.navigate("Login"); + } else { + const text = await response.text(); + console.error("회원 탈퇴 실패 응답:", text); + Alert.alert("오류", "회원 탈퇴에 실패했습니다."); + } + } catch (err) { + console.error("회원 탈퇴 중 오류:", err); + Alert.alert("오류", "네트워크 오류로 탈퇴에 실패했습니다."); + } }, - ] - ); - }; + }, + ] + ); +}; useEffect(() => { const loadUserData = async () => { @@ -210,7 +304,11 @@ const MyPageScreen = ({ navigation }) => { return; } - await fetchUserInfo(navigation, setUserInfo); + // 사용자 정보와 MBTI 추천 정보를 병렬로 로드 + await Promise.all([ + fetchUserInfo(navigation, setUserInfo), + fetchMbtiRecommendations(), + ]); } catch (err) { console.error("❌ 사용자 정보 불러오기 실패:", err); Alert.alert("오류", "사용자 정보를 불러오지 못했습니다."); @@ -222,6 +320,14 @@ const MyPageScreen = ({ navigation }) => { loadUserData(); }, []); + useEffect(() => { + const unsubscribe = navigation.addListener("focus", () => { + fetchMbtiRecommendations(); + }); + + return unsubscribe; + }, [navigation]); + if (loading) { return ( @@ -232,106 +338,137 @@ const MyPageScreen = ({ navigation }) => { return ( - - {/* 왼쪽: 이미지 + 닉네임 */} - - - - - {/* 오른쪽: 뱃지 + 한줄소개 */} - - - {equippedBadges.map((badge, index) => ( - - {badge} - - ))} - - - {userInfo?.nickname || "잔고가 두둑한 햄스터"} - - - - setIsEditingIntro(true)} - /> - {isEditingIntro ? ( - setIsEditingIntro(false)} - style={styles.introInput} - autoFocus + {/* ✅ 화면 전체 스크롤 */} + + {/* 프로필 섹션 */} + + + {/* 프로필 이미지 */} + + - ) : ( - setIsEditingIntro(true)}> - : {introText} - - )} + + + + {/* 유저 정보 */} + + + {userInfo?.nickname || "잔고가 두둑한 햄스터"} + + + {/* MBTI 별명 */} + {aliasLoading ? ( + + + + + ) : mbtiAlias ? ( + "{mbtiAlias}" + ) : ( + 별명을 불러오는 중... + )} + + + {userInfo?.email && ( + + + + {userInfo.email /* 마스킹은 필요 시 maskEmail 사용 */} + + + )} + + {userInfo?.birthdate && ( + + + + {formatBirthdate(userInfo.birthdate)} + + + {calculateBirthdayDday(userInfo.birthdate)} + + + )} + + - - - 🐹 돌려돌려 돌림판 🐹 - - {/* { - try { - const message = await increaseBalance(navigation, DEPOSIT_AMOUNT); - Alert.alert("출석 보상 받기", message); - } catch (error) { - Alert.alert("에러", error.message || "보상 받기에 실패했습니다."); - } - }} - > - 출석 보상 받기 - */} - - navigation.navigate("Roulette")} - > - 출석 보상 받으러 가기 - - + + + {/* 돌림판 섹션 */} + + 📢 진행 중인 이벤트 + navigation.navigate("Roulette")} + > + + 일일 룰렛 돌리기 + + + + - + - - navigation.navigate("EditUserInfo")} - /> - navigation.navigate("Notice")} - /> - navigation.navigate("FAQ")} - /> - navigation.navigate("ChangePassword")} - /> - - + {/* 메뉴 섹션 (내부 스크롤 ❌) */} + + + navigation.navigate("Notice")} + iconColor="#6EE69E" + /> + navigation.navigate("FAQ")} + iconColor="#6EE69E" + /> + navigation.navigate("ChangePassword")} + iconColor="#6EE69E" + /> + + + + + ); @@ -341,134 +478,152 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#003340", - paddingHorizontal: 30, - paddingTop: 60, + paddingHorizontal: 20, + paddingTop: 50, + }, + + // 전체 스크롤 뷰 + scrollView: { + flex: 1, }, + scrollContent: { + paddingBottom: 24, + }, + + // 프로필 섹션 profileSection: { - flexDirection: "row", - alignItems: "center", - marginTop: 30, - marginBottom: 0, + marginTop: 20, + marginBottom: 10, }, - profileLeft: { + profileCard: { + backgroundColor: "rgba(255, 255, 255, 0.09)", + borderRadius: 20, + padding: 25, + flexDirection: "row", alignItems: "center", - marginLeft: 10, - marginRight: 30, - }, - profileRight: { - flex: 1, - justifyContent: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, }, + profileImageContainer: { position: "relative", marginRight: 20 }, profileImage: { - width: 100, - height: 100, + width: 95, + height: 95, borderRadius: 50, - borderWidth: 3, - borderColor: "#FFFFFFB0", - backgroundColor: "#D4DDEF60", + backgroundColor: "rgba(212, 221, 239, 0.2)", }, - badgeRow: { - flexDirection: "row", - justifyContent: "flex-start", - marginBottom: 0, - }, - badgeBox: { - backgroundColor: "#FFFFFF80", + profileImageShadow: { + position: "absolute", + top: 0, + left: 0, + width: 95, + height: 95, borderRadius: 50, - paddingVertical: 6, - paddingHorizontal: 6, - marginRight: 8, - }, - badgeText: { - fontSize: 15, - color: "white", - fontWeight: "bold", + backgroundColor: "transparent", + borderWidth: 1, + borderColor: "rgba(247, 206, 229, 0.3)", }, + userInfoContainer: { flex: 1, justifyContent: "center", backgroundColor: "transparent" }, userName: { - fontSize: 18, - fontWeight: "bold", - color: "#F8C7CC", - marginTop: 10, - marginBottom: 5, + fontSize: 22, + fontWeight: "700", + color: "#FFD1EB", + marginBottom: 4, + marginTop: 5, + letterSpacing: 0.5, }, - introRow: { - flexDirection: "row", - alignItems: "center", - marginTop: 0, - marginLeft: 0, + mbtiAlias: { + fontSize: 14, + fontWeight: "400", + color: "#dadadaff", + marginBottom: 13, + letterSpacing: 0.3, }, - introText: { - fontSize: 15, - color: "#EEEEEE", + mbtiAliasEmpty: { + fontSize: 12, + fontWeight: "400", + color: "rgba(168, 230, 207, 0.5)", + marginBottom: 13, }, - introInput: { + aliasLoadingContainer: { flexDirection: "row", alignItems: "center", marginBottom: 8 }, + aliasLoadingText: { fontSize: 12, color: "#dadadaff", marginLeft: 6, fontStyle: "italic" }, + userDetailsContainer: { gap: 6 }, + userDetailRow: { flexDirection: "row", alignItems: "center" }, + detailIcon: { marginRight: 8, width: 16 }, + userDetailText: { fontSize: 14, - color: "white", - borderBottomWidth: 1, - borderBottomColor: "#888", - flex: 1, + color: "#B8C5D1", + fontWeight: "400", + letterSpacing: 0.2, }, + birthdayDday: { + fontSize: 12, + color: "#fb9dd2ff", + fontWeight: "600", + marginLeft: 8, + paddingHorizontal: 6, + paddingVertical: 2, + backgroundColor: "rgba(254, 212, 236, 0.1)", + borderRadius: 8, + overflow: "hidden", + }, + + // 구분선 divider: { height: 1, - backgroundColor: "#4A5A60", - marginVertical: 20, + backgroundColor: "rgba(255, 255, 255, 0.1)", + marginVertical: 25, }, + + // 돌림판 섹션 + rouletteSection: { marginBottom: 10 }, moneyTitle: { - color: "#EEEEEE", - fontSize: 18, - marginBottom: 20, - marginLeft: 15, - marginTop: 5, - fontWeight: "600", + color: "#c6d4e1ff", + fontSize: 17, + marginBottom: 15, + fontWeight: "500", + textAlign: "left", + marginLeft: 4, }, - moneyButtonContainer: { - flexDirection: "row", - justifyContent: "space-between", - marginBottom: 10, + rouletteButton: { + backgroundColor: "#F074BA", + borderRadius: 16, + paddingVertical: 18, + paddingHorizontal: 20, + shadowColor: "#F074BA", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 8, + elevation: 6, }, - tiggleButton: { - flex: 1, - backgroundColor: "#5DB996E0", - paddingVertical: 20, - borderRadius: 20, - marginHorizontal: 10, - alignItems: "center", - }, - taesanButton: { - flex: 1, - backgroundColor: "#F074BAE0", - paddingVertical: 20, - borderRadius: 20, - marginHorizontal: 10, - alignItems: "center", + rouletteButtonContent: { flexDirection: "row", alignItems: "center", justifyContent: "center" }, + rouletteButtonText: { + color: "#FFFFFF", + fontSize: 17, + fontWeight: "600", + marginRight: 10, + letterSpacing: 0.3, }, - moneyButtonText: { - fontFamily: "Times New Roman", - color: "#EFF1F5", - fontSize: 18, - fontWeight: "500", + + // 메뉴 섹션 (내부 스크롤 제거) + menuSectionContainer: { + marginTop: 0, }, menuContainer: { - paddingVertical: 0, - paddingHorizontal: 0, - }, - menuRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", + paddingBottom: 10, }, menuButton: { - backgroundColor: "#D4DDEF30", - padding: 15, - borderRadius: 10, - marginBottom: 13, - marginHorizontal: 5, - }, - menuText: { - fontSize: 16, - color: "white", - fontWeight: "bold", + backgroundColor: "rgba(255, 255, 255, 0.06)", + borderRadius: 12, + paddingVertical: 16, + paddingHorizontal: 18, + marginBottom: 10, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.08)", }, + menuRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + menuText: { fontSize: 17, color: "#FFFFFF", fontWeight: "500", letterSpacing: 0.2 }, }); export default MyPageScreen; diff --git a/src/screens/MyPage/RouletteScreen.js b/src/screens/MyPage/RouletteScreen.js index 0598509..a341ac6 100644 --- a/src/screens/MyPage/RouletteScreen.js +++ b/src/screens/MyPage/RouletteScreen.js @@ -14,60 +14,39 @@ import { ImageBackground, } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; -import Svg, { G, Path, Text as SvgText } from 'react-native-svg'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import Svg, { G, Path, Text as SvgText, Defs, RadialGradient, Stop, Circle } from 'react-native-svg'; import { increaseBalance } from '../../utils/point'; -const { width } = Dimensions.get('window'); -const WHEEL_SIZE = width * 0.75; -const BORDER_WIDTH = 12; +const { width, height } = Dimensions.get('window'); +const WHEEL_SIZE = width * 0.8; +const BORDER_WIDTH = 8; const TOTAL_SIZE = WHEEL_SIZE + BORDER_WIDTH * 2; const RADIUS = WHEEL_SIZE / 2; const SEGMENTS = 8; const SEGMENT_ANGLE = 360 / SEGMENTS; -// const prizes = [ -// '₩100,000', -// '₩50,000', -// '₩30,000', -// '₩200,000', -// '₩75,000', -// '₩30,000', -// '₩150,000', -// '₩80,000', -// ]; - 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만' }, ]; - -// 무지개 8색 팔레트 -// const segmentColors = [ -// '#FF3B30', // 빨강 -// '#FF9500', // 주황 -// '#FFCC00', // 노랑 -// '#34C759', // 초록 -// '#5AC8FA', // 청록 -// '#007AFF', // 파랑 -// '#5856D6', // 보라 -// '#FF2D95', // 분홍 -// ]; +// 세련된 그라데이션 색상 팔레트 const segmentColors = [ - '#335696D0', // 빨강 - '#003340D0', // 주황 - '#335696D0', // 노랑 - '#003340D0', // 초록 - '#335696D0', // 청록 - '#003340D0', // 파랑 - '#335696D0', // 보라 - '#003340D0', // 분홍 + '#F074BA', // 메인 핑크 + '#335696', // 메인 블루 + '#FF6B9D', // 밝은 핑크 + '#4A90E2', // 밝은 블루 + '#E91E63', // 진한 핑크 + '#2196F3', // 진한 블루 + '#FF8A80', // 코랄 핑크 + '#64B5F6', // 스카이 블루 ]; // SVG 헬퍼 함수 @@ -75,6 +54,7 @@ const polarToCartesian = (cx, cy, r, angleDeg) => { const a = ((angleDeg - 90) * Math.PI) / 180; return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }; }; + const describeArc = (cx, cy, r, startAngle, endAngle) => { const start = polarToCartesian(cx, cy, r, endAngle); const end = polarToCartesian(cx, cy, r, startAngle); @@ -86,35 +66,71 @@ const AnimatedSvg = Animated.createAnimatedComponent(Svg); export default function RouletteScreen({ navigation }) { const spinAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(1)).current; const [spinning, setSpinning] = useState(false); const barHeight = Platform.OS === 'android' ? StatusBar.currentHeight : 0; - const spinWheel = () => { + // 펄스 애니메이션 효과 + 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]); + + const spinWheel = async () => { if (spinning) return; setSpinning(true); - const rounds = Math.floor(Math.random() * 3) + 3; + + // 펄스 애니메이션 중지 + pulseAnim.setValue(1); + + const rounds = Math.floor(Math.random() * 3) + 4; // 4-6바퀴 const idx = Math.floor(Math.random() * SEGMENTS); const deg = rounds * 360 + idx * SEGMENT_ANGLE + SEGMENT_ANGLE / 2; Animated.timing(spinAnim, { toValue: deg, - duration: 3500, - easing: Easing.out(Easing.cubic), + duration: 4000, // 4초로 늘림 + easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // 부드러운 이징 useNativeDriver: true, - }).start(() => { + }).start(async () => { const final = deg % 360; const selected = SEGMENTS - Math.floor(final / SEGMENT_ANGLE) - 1; const prize = prizes[selected]; - const amount = parseInt(prize.replace(/[₩,]/g, ''), 10) * 10000; - - increaseBalance(navigation, amount) - .then(msg => Alert.alert('축하합니다! 🎉', `${prize} 당첨!\n${msg}`)) - .catch(() => Alert.alert('오류', '룰렛은 하루에 한 번!\n내일 다시 시도해보세요.')) - //.catch(() => Alert.alert('오류', '포인트 적립에 실패했습니다.')) - .finally(() => { - setSpinning(false); - spinAnim.setValue(final); - }); + + try { + const msg = await increaseBalance(navigation, prize.value); + // 성공 시 + Alert.alert('🎉 축하합니다!', `${prize.amount} 당첨!\n\n${msg}`, [ + { text: '확인', style: 'default' } + ]); + } catch (error) { + // 모든 에러를 조용히 처리 + Alert.alert( + '⏰ 오늘의 기회 소진', + '룰렛은 하루에 한 번만 도전 가능합니다.\n내일 다시 도전해보세요! 🍀', + [{ text: '확인', style: 'default' }] + ); + } finally { + setSpinning(false); + spinAnim.setValue(final); + } }); }; @@ -124,16 +140,16 @@ export default function RouletteScreen({ navigation }) { }); return ( - + + + {/* 배경 그라데이션 */} + + {/* 헤더 */} @@ -141,129 +157,251 @@ export default function RouletteScreen({ navigation }) { onPress={() => navigation.goBack()} style={styles.backButton} > - {'<'} + - 돌려돌려 돌림판 + 데일리 룰렛 + + + + {/* 부제목 */} + + 매일 한 번의 특별한 기회 + 운을 시험해보세요! 🍀 - {/* 룰렛 */} - - - + {/* 룰렛 컨테이너 */} + + {/* 외부 장식 링 */} + + + {/* 포인터 */} + + + - {/* {[0, 90, 180, 270].map((angle, i) => ( - - ))} */} - - - - {prizes.map((label, i) => { - const start = i * SEGMENT_ANGLE; - const end = start + SEGMENT_ANGLE; - const path = describeArc(RADIUS, RADIUS, RADIUS, start, end); - const fill = segmentColors[i]; - const mid = start + SEGMENT_ANGLE / 2; - const angleRad = ((mid - 90) * Math.PI) / 180; - const tx = RADIUS + RADIUS * 0.65 * Math.cos(angleRad); - const ty = RADIUS + RADIUS * 0.65 * Math.sin(angleRad); - return ( - - - - {label} - - - ); - })} - - - - - {spinning ? '돌리는 중...' : 'START'} - - + + + + {segmentColors.map((color, index) => ( + + + + + ))} + + + {prizes.map((prize, i) => { + const start = i * SEGMENT_ANGLE; + const end = start + SEGMENT_ANGLE; + const path = describeArc(RADIUS, RADIUS, RADIUS - 2, start, end); + const fill = `url(#gradient${i})`; + const mid = start + SEGMENT_ANGLE / 2; + const angleRad = ((mid - 90) * Math.PI) / 180; + const tx = RADIUS + RADIUS * 0.7 * Math.cos(angleRad); + const ty = RADIUS + RADIUS * 0.7 * Math.sin(angleRad); + + return ( + + + + {prize.display} + + + 원 + + + ); + })} + + + + {/* 중앙 버튼 */} + + + {spinning ? ( + <> + + 돌리는 중... + + ) : ( + <> + + START + + )} + + + + + + {/* 하단 정보 */} + + + + 하루 한 번 무료로 도전 가능 + + + + 최대 30만원까지 획득 + - + ); } const styles = StyleSheet.create({ - background: { + container: { flex: 1, - width: '100%', - height: '100%', + backgroundColor: '#003340', }, - image: { - resizeMode: 'cover', + backgroundGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: height * 0.6, + backgroundColor: '#003340', + opacity: 0.95, }, safeArea: { flex: 1, - backgroundColor: 'transparent', }, header: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, - height: 56, + justifyContent: 'space-between', + paddingHorizontal: 20, + height: 60, + marginTop: 10, }, backButton: { - position: 'absolute', - top: 10, - left: 20, - zIndex: 10, - }, - backText: { - fontSize: 36, - color: '#F074BA', + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(239, 241, 245, 0.1)', + borderRadius: 22, }, title: { - flex: 1, - textAlign: 'center', - top: 5, - color: '#FFF', fontSize: 24, + fontWeight: '700', + color: '#EFF1F5', + textAlign: 'center', + }, + headerRight: { + width: 44, + }, + subtitleContainer: { + alignItems: 'center', + marginTop: 20, + marginBottom: 30, + }, + subtitle: { + fontSize: 18, + color: '#F074BA', fontWeight: '600', + marginBottom: 8, }, - wheelWrapper: { + description: { + fontSize: 16, + color: 'rgba(239, 241, 245, 0.8)', + fontWeight: '400', + }, + wheelContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', + position: 'relative', }, - pinTop: { + outerRing: { position: 'absolute', - top: -(56 / 2) - BORDER_WIDTH + 160, - zIndex: 5, + width: TOTAL_SIZE + 20, + height: TOTAL_SIZE + 20, + borderRadius: (TOTAL_SIZE + 20) / 2, + borderWidth: 3, + borderColor: 'rgba(240, 116, 186, 0.3)', + backgroundColor: 'rgba(240, 116, 186, 0.05)', }, - accentLine: { + pointerContainer: { position: 'absolute', - width: 2, - height: WHEEL_SIZE * 0.5, - backgroundColor: '#FFF', - zIndex: 3, + top: -25, + zIndex: 10, + }, + pointer: { + width: 0, + height: 0, + backgroundColor: 'transparent', + borderStyle: 'solid', + borderLeftWidth: 15, + borderRightWidth: 15, + borderBottomWidth: 40, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + borderBottomColor: '#F074BA', + }, + pointerShadow: { + position: 'absolute', + top: 2, + left: -13, + width: 0, + height: 0, + backgroundColor: 'transparent', + borderStyle: 'solid', + borderLeftWidth: 13, + borderRightWidth: 13, + borderBottomWidth: 36, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + borderBottomColor: 'rgba(0, 0, 0, 0.2)', + zIndex: -1, + }, + wheelWrapper: { + position: 'relative', + alignItems: 'center', + justifyContent: 'center', }, wheelBorder: { position: 'absolute', @@ -271,37 +409,67 @@ const styles = StyleSheet.create({ height: TOTAL_SIZE, borderRadius: TOTAL_SIZE / 2, borderWidth: BORDER_WIDTH, - //borderColor: '#F074BA', - borderColor: '#FFFFFFC0', - zIndex: 1, + borderColor: '#EFF1F5', + backgroundColor: 'transparent', + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 12, }, wheel: { position: 'absolute', - width: WHEEL_SIZE, - height: WHEEL_SIZE, - zIndex: 2, }, centerButton: { position: 'absolute', - width: WHEEL_SIZE * 0.4, - height: WHEEL_SIZE * 0.4, - borderRadius: (WHEEL_SIZE * 0.4) / 2, - backgroundColor: '#FFF', + width: WHEEL_SIZE * 0.35, + height: WHEEL_SIZE * 0.35, + borderRadius: (WHEEL_SIZE * 0.35) / 2, + backgroundColor: '#EFF1F5', alignItems: 'center', justifyContent: 'center', - zIndex: 4, shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 6, - elevation: 8, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 10, + borderWidth: 4, + borderColor: '#FFFFFF', + }, + centerButtonDisabled: { + opacity: 0.8, + transform: [{ scale: 0.95 }], + }, + centerButtonInner: { + alignItems: 'center', + justifyContent: 'center', }, - centerText: { + centerButtonText: { color: '#003340', - fontSize: 18, + fontSize: 16, fontWeight: '700', + marginTop: 4, + }, + bottomInfo: { + paddingHorizontal: 30, + paddingBottom: 30, + marginTop: 20, + }, + infoCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(239, 241, 245, 0.05)', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + marginBottom: 10, + borderLeftWidth: 4, + borderLeftColor: '#F074BA', }, - disabled: { - opacity: 0.6, + infoText: { + color: '#EFF1F5', + fontSize: 14, + fontWeight: '500', + marginLeft: 12, }, -}); +}); \ No newline at end of file diff --git a/src/services/PushNotificationService.ios.js b/src/services/PushNotificationService.ios.js index cc2b4ba..9ddc46f 100644 --- a/src/services/PushNotificationService.ios.js +++ b/src/services/PushNotificationService.ios.js @@ -1,170 +1,188 @@ // src/services/PushNotificationService.ios.js -import { Platform, Alert } from 'react-native'; -import * as Notifications from 'expo-notifications'; -import * as Device from 'expo-device'; -import Constants from 'expo-constants'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { API_BASE_URL } from '../utils/apiConfig'; -import { getNewAccessToken } from '../utils/token'; - -// iOS: 포그라운드에서도 배너/사운드/배지 표시 -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - }), -}); - -const DEVICE_ID_KEY = 'deviceId'; -const PUSH_TOKEN_KEY = 'pushToken'; -const generateDeviceId = () => - `ios-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; - -export async function registerPushToken(navigation) { +import { Platform } from "react-native"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import Constants from "expo-constants"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { API_BASE_URL } from "../utils/apiConfig"; + +// ============================ +// 내부 유틸 +// ============================ +async function getOrCreateDeviceId() { + const KEY = "deviceId"; + let id = await AsyncStorage.getItem(KEY); + if (id) return id; + + const rand = Math.random().toString(36).slice(2, 10); + const stamp = Date.now().toString(36); + const model = (Device.modelId || "ios").toString().replace(/[^a-zA-Z0-9_-]/g, ""); + id = `ios-${model}-${stamp}-${rand}`; + await AsyncStorage.setItem(KEY, id); + return id; +} + +async function saveLocalToken(token) { try { - if (!Device.isDevice) { - console.log('⚠️ 시뮬레이터에서는 푸시 알림이 제한될 수 있어요.'); - } + await AsyncStorage.setItem("pushToken", token); + } catch {} +} +async function loadLocalToken() { + try { + return (await AsyncStorage.getItem("pushToken")) || null; + } catch { + return null; + } +} - // 권한 확인/요청 - const { status: existingStatus } = await Notifications.getPermissionsAsync(); - let finalStatus = existingStatus; - if (existingStatus !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync(); - finalStatus = status; - } - if (finalStatus !== 'granted') { - Alert.alert('알림 권한이 필요합니다', '설정 > 알림에서 허용해 주세요.'); - return false; - } +// ============================ +// 서버 API 연동 (/api/push-tokens) +// ============================ +async function uploadTokenToServer(token) { + try { + const deviceId = await getOrCreateDeviceId(); + const accessToken = await AsyncStorage.getItem("accessToken"); // 🔑 로그인 토큰 + + // 🚀 서버에 저장할 body (문자열 그대로 ExponentPushToken[...] 포함) + const body = { + token, // ExponentPushToken[...] 형태 그대로 + 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), + }); - // EAS projectId (Dev Client/빌드에서 자동 노출) - const projectId = - Constants?.easConfig?.projectId ?? - Constants?.expoConfig?.extra?.eas?.projectId; - if (!projectId) { - console.warn('❌ projectId가 없습니다. app.json의 extra.eas.projectId를 확인하세요.'); + if (!res.ok) { + console.warn("[Push] 등록 실패:", res.status, await res.text()); return false; } - // Expo Push Token 발급 (ExponentPushToken[...]) - const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId }); - console.log('🍎 Expo Push Token(iOS):', expoPushToken); - - // deviceId 준비 - let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY); - if (!deviceId) { - deviceId = generateDeviceId(); - await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId); - } - - // 서버 등록 - const ok = await sendTokenToServer(expoPushToken, deviceId, navigation); - await AsyncStorage.setItem(PUSH_TOKEN_KEY, expoPushToken); - if (!ok) console.warn('⚠️ 서버 등록 실패(로컬 저장은 완료)'); - + console.log("[Push] 등록 성공:", body); + await saveLocalToken(token); return true; } catch (e) { - console.error('푸시 토큰 등록 실패:', e); + console.warn("[Push] 등록 오류:", e?.message || e); return false; } } -async function sendTokenToServer(token, deviceId, navigation) { +async function deleteTokenFromServer(token) { try { - const accessToken = await getNewAccessToken(navigation); - if (!accessToken) { - console.error('❌ 액세스 토큰 없음'); - return false; - } - - const res = await fetch(`${API_BASE_URL}api/push-tokens/`, { - method: 'POST', + const accessToken = await AsyncStorage.getItem("accessToken"); + const res = await fetch(`${API_BASE_URL}api/push-tokens`, { + method: "DELETE", headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, - body: JSON.stringify({ - token, // ExponentPushToken[...] - deviceId, - platform: 'ios', // iOS 고정 - }), + body: JSON.stringify({ token }), }); - const ct = res.headers.get('content-type') || ''; - const json = ct.includes('application/json') ? await res.json().catch(() => null) : null; - - if (res.ok && json?.ok) { - console.log('✅ 토큰 서버 등록 성공:', json.created ? '신규' : '기존'); - return true; - } else { - const text = !json ? (await res.text().catch(() => '')).slice(0, 200) : ''; - console.warn('❌ 서버 등록 실패:', res.status, json || text); + if (!res.ok) { + console.warn("[Push] 해제 실패:", res.status, await res.text()); return false; } + + console.log("[Push] 해제 성공:", token); + return true; } catch (e) { - console.error('❌ 서버 전송 오류:', e); + console.warn("[Push] 해제 오류:", e?.message || e); return false; } } -export async function unregisterPushToken(navigation) { +// ============================ +// 퍼블릭 API +// ============================ +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 { - const stored = await AsyncStorage.getItem(PUSH_TOKEN_KEY); - if (!stored) { - console.log('📱 등록된 토큰 없음'); - return true; - } - const accessToken = await getNewAccessToken(navigation); + if (Platform.OS !== "ios") return { success: true, skipped: "not_ios" }; + if (!Device.isDevice) return { success: true, skipped: "simulator" }; - 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: stored }), - }); + const perm = await ensurePermissions(); + if (perm !== "granted") return { success: true, skipped: "perm_denied" }; - await AsyncStorage.removeItem(PUSH_TOKEN_KEY); - console.log(res.ok ? '✅ 토큰 해제 완료' : '⚠️ 서버 해제 실패(로컬 제거 완료)'); - return true; + const projectId = getProjectId(); + if (!projectId) return { success: true, skipped: "no_project_id" }; + + const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId }); + + console.log("📢 [Push] ExpoPushToken:", expoPushToken); + + const uploaded = await uploadTokenToServer(expoPushToken); + return { success: true, expoPushToken, uploaded }; } catch (e) { - await AsyncStorage.removeItem(PUSH_TOKEN_KEY); - console.warn('토큰 해제 중 오류(로컬 제거 완료):', e); - return true; + console.warn("[Push][ERR] registerExpoPushToken:", e?.message || String(e)); + return { success: false, error: e?.message || String(e) }; } } -export function setupNotificationListeners(navigation) { - // 포그라운드 수신 - const subRecv = Notifications.addNotificationReceivedListener((n) => { - console.log('📥(iOS) 포그라운드 수신:', n?.request?.content); - // 필요 시: 공지 자동 이동 등 - }); +// 로그아웃/탈퇴 시 토큰 해제 +export async function unregisterPushToken() { + try { + let token = await loadLocalToken(); + + if (!token && Device.isDevice && Platform.OS === "ios") { + const projectId = getProjectId(); + if (projectId) { + const { data } = await Notifications.getExpoPushTokenAsync({ projectId }); + token = data; + } + } - // 탭(백그라/종료 → 복귀) - const subResp = Notifications.addNotificationResponseReceivedListener((r) => { - const data = r?.notification?.request?.content?.data || {}; - console.log('👆(iOS) 알림 탭:', data); - if (data?.screen && navigation?.navigate) { - navigation.navigate(data.screen, data.params || {}); - } else if (data?.type === 'notice') { - navigation?.navigate?.('NoticeScreen'); + if (!token) { + console.warn("[Push] 해제 스킵: 로컬 토큰 없음"); + return false; } - }); - // 콜드 스타트 딥링크 처리 - Notifications.getLastNotificationResponseAsync().then((initial) => { - const data = initial?.notification?.request?.content?.data; - if (data?.screen && navigation?.navigate) { - navigation.navigate(data.screen, data.params || {}); + const ok = await deleteTokenFromServer(token); + if (ok) { + await AsyncStorage.removeItem("pushToken"); } + return ok; + } catch (e) { + console.warn("[Push] unregister 오류:", e?.message || e); + return false; + } +} + +// 알림 리스너 +export function setupNotificationListeners() { + const recvSub = Notifications.addNotificationReceivedListener((n) => { + console.log("[Push][recv]:", n); + }); + const respSub = Notifications.addNotificationResponseReceivedListener((r) => { + console.log("[Push][tap]:", r); }); return () => { - subRecv && Notifications.removeNotificationSubscription(subRecv); - subResp && Notifications.removeNotificationSubscription(subResp); + Notifications.removeNotificationSubscription(recvSub); + Notifications.removeNotificationSubscription(respSub); }; } diff --git a/src/utils/point.js b/src/utils/point.js index 56391ad..af26e6d 100644 --- a/src/utils/point.js +++ b/src/utils/point.js @@ -17,8 +17,16 @@ export const increaseBalance = async (navigation, amount) => { const text = await response.text(); console.log("예수금 추가 응답 본문:", text); + // 400 에러(이미 사용함)의 경우 조용히 처리 + if (response.status === 400) { + console.log("⏰ 룰렛 이미 사용함 - 하루 한 번 제한"); + // throw 하지 말고 Promise.reject로 조용히 처리 + return Promise.reject("already_used_today"); + } + if (!response.ok) { - throw new Error(`API 호출 실패: ${response.status}`); + console.log(`❌ API 호출 실패: ${response.status}`); + return Promise.reject(`API 호출 실패: ${response.status}`); } const data = JSON.parse(text); @@ -26,10 +34,19 @@ export const increaseBalance = async (navigation, amount) => { if (data.status === "success") { return data.message; } else { - throw new Error(data.message || "알 수 없는 오류 발생"); + console.log("❌ 서버 에러:", data.message); + return Promise.reject(data.message || "알 수 없는 오류 발생"); } } catch (err) { - console.error("예수금 추가 실패:", err); - throw err; + // 네트워크 에러나 기타 에러도 조용히 처리 + console.log("예수금 추가 실패 (조용히 처리):", err.message || err); + + // JSON 파싱 에러나 네트워크 에러의 경우 + if (err.message && err.message.includes('JSON')) { + return Promise.reject("parsing_error"); + } + + // 기타 에러의 경우 + return Promise.reject("network_error"); } -}; +}; \ No newline at end of file