From d10d2bf23ea922254db7f48cae3a39254f8605bd Mon Sep 17 00:00:00 2001 From: thwogh Date: Fri, 27 Jun 2025 16:29:55 +0900 Subject: [PATCH 01/14] =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8,=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=99=88?= =?UTF-8?q?=20=EC=8A=A4=ED=83=9D=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 + .eslintignore | 3 +- .prettierignore | 2 +- app.config.js | 37 + app.json | 2 +- app/(tabs)/_layout.tsx | 46 + app/(tabs)/archive.tsx | 6 + app/(tabs)/awards.tsx | 6 + app/(tabs)/home.tsx | 6 + app/(tabs)/profile.tsx | 6 + app/(tabs)/upload.tsx | 6 + app/_layout.tsx | 26 +- app/index.tsx | 32 +- app/login.tsx | 6 + app/login/index.tsx | 9 - babel.config.js | 19 + features/auth/hooks/useLogin.ts | 3 - features/auth/model/AuthContext.tsx | 58 + features/auth/model/types.ts | 14 + features/auth/ui/GoogleLoginButton.tsx | 11 + features/auth/ui/KakaoLoginButton.tsx | 47 + features/auth/ui/KakaoLoginWebView.tsx | 137 + features/auth/ui/NaverLoginButton.tsx | 11 + features/auth/ui/SocialLoginButtons.tsx | 25 +- global.css | 3 + metro.config.js | 8 + package-lock.json | 2020 ++-- package.json | 9 +- pages/archive/ArchivePage.tsx | 13 + pages/auth/LoginPage.tsx | 36 + pages/awards/AwardsPage.tsx | 13 + pages/home/HomePage.tsx | 24 + pages/profile/ProfilePage.tsx | 17 + pages/upload/UploadPage.tsx | 13 + .../assets}/fonts/SpaceMono-Regular.ttf | Bin {assets => shared/assets}/icons/index.ts | 0 {assets => shared/assets}/icons/search.svg | 0 shared/assets/images/auth/authBackground.png | Bin 0 -> 388002 bytes shared/assets/images/auth/kakaoLogo.png | Bin 0 -> 321 bytes {assets => shared/assets}/types/svg.d.ts | 0 shared/ui/icon/index.tsx | 2 +- tailwind.config.js | 16 + tsconfig.json | 6 +- yarn.lock | 8409 +++++++++++++++++ 44 files changed, 10509 insertions(+), 600 deletions(-) create mode 100644 .env create mode 100644 app.config.js create mode 100644 app/(tabs)/_layout.tsx create mode 100644 app/(tabs)/archive.tsx create mode 100644 app/(tabs)/awards.tsx create mode 100644 app/(tabs)/home.tsx create mode 100644 app/(tabs)/profile.tsx create mode 100644 app/(tabs)/upload.tsx create mode 100644 app/login.tsx delete mode 100644 app/login/index.tsx create mode 100644 babel.config.js delete mode 100644 features/auth/hooks/useLogin.ts create mode 100644 features/auth/model/AuthContext.tsx create mode 100644 features/auth/model/types.ts create mode 100644 features/auth/ui/GoogleLoginButton.tsx create mode 100644 features/auth/ui/KakaoLoginButton.tsx create mode 100644 features/auth/ui/KakaoLoginWebView.tsx create mode 100644 features/auth/ui/NaverLoginButton.tsx create mode 100644 global.css create mode 100644 metro.config.js create mode 100644 pages/archive/ArchivePage.tsx create mode 100644 pages/auth/LoginPage.tsx create mode 100644 pages/awards/AwardsPage.tsx create mode 100644 pages/home/HomePage.tsx create mode 100644 pages/profile/ProfilePage.tsx create mode 100644 pages/upload/UploadPage.tsx rename {assets => shared/assets}/fonts/SpaceMono-Regular.ttf (100%) rename {assets => shared/assets}/icons/index.ts (100%) rename {assets => shared/assets}/icons/search.svg (100%) create mode 100644 shared/assets/images/auth/authBackground.png create mode 100644 shared/assets/images/auth/kakaoLogo.png rename {assets => shared/assets}/types/svg.d.ts (100%) create mode 100644 tailwind.config.js create mode 100644 yarn.lock diff --git a/.env b/.env new file mode 100644 index 0000000..bcb32df --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +EXPO_PUBLIC_KAKAO_REST_API_KEY=fcc79e9199b5dbcaedfc00bb30b3d4af +EXPO_PUBLIC_SERVER_BASE_URL=https://api.dailysnap.app diff --git a/.eslintignore b/.eslintignore index 290b2fe..ad6d66f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,5 @@ node_modules android ios build -dist \ No newline at end of file +dist +tailwind.config.js diff --git a/.prettierignore b/.prettierignore index 290b2fe..c9745b2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,4 @@ node_modules android ios build -dist \ No newline at end of file +dist diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..0b762ee --- /dev/null +++ b/app.config.js @@ -0,0 +1,37 @@ +export default { + expo: { + name: "DailySnap-FE", + slug: "DailySnap-FE", + version: "1.0.0", + orientation: "portrait", + scheme: "dailysnap", + userInterfaceStyle: "light", + newArchEnabled: true, + splash: { + resizeMode: "contain", + backgroundColor: "#ffffff" + }, + ios: { + supportsTablet: true, + bundleIdentifier: "com.jhsonny.DailySnapFE" + }, + android: { + adaptiveIcon: { + backgroundColor: "#ffffff" + }, + package: "com.jhsonny.DailySnapFE" + }, + web: { + bundler: "metro", + output: "static", + favicon: "./assets/images/favicon.png" + }, + plugins: [ + "expo-router", + "expo-font" + ], + experiments: { + typedRoutes: true + } + } +}; \ No newline at end of file diff --git a/app.json b/app.json index b89d0c3..f42e895 100644 --- a/app.json +++ b/app.json @@ -26,7 +26,7 @@ "output": "static", "favicon": "./assets/images/favicon.png" }, - "plugins": ["expo-router", "expo-font"], + "plugins": ["expo-router", "expo-font", "@react-native-kakao/core"], "experiments": { "typedRoutes": true } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..d6ddf4d --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,46 @@ +import { Tabs } from "expo-router"; + +export default function TabsLayout() { + return ( + + + + + + + + + + + + ); +} diff --git a/app/(tabs)/archive.tsx b/app/(tabs)/archive.tsx new file mode 100644 index 0000000..9ec979d --- /dev/null +++ b/app/(tabs)/archive.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import ArchivePage from "../../pages/archive/ArchivePage"; + +export default function ArchiveTab() { + return ; +} diff --git a/app/(tabs)/awards.tsx b/app/(tabs)/awards.tsx new file mode 100644 index 0000000..fee2bcd --- /dev/null +++ b/app/(tabs)/awards.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import AwardsPage from "../../pages/awards/AwardsPage"; + +export default function AwardsTab() { + return ; +} diff --git a/app/(tabs)/home.tsx b/app/(tabs)/home.tsx new file mode 100644 index 0000000..278d10e --- /dev/null +++ b/app/(tabs)/home.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import HomePage from "../../pages/home/HomePage"; + +export default function HomeTab() { + return ; +} diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx new file mode 100644 index 0000000..4e2c56c --- /dev/null +++ b/app/(tabs)/profile.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import ProfilePage from "../../pages/profile/ProfilePage"; + +export default function ProfileTab() { + return ; +} diff --git a/app/(tabs)/upload.tsx b/app/(tabs)/upload.tsx new file mode 100644 index 0000000..eb94384 --- /dev/null +++ b/app/(tabs)/upload.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import UploadPage from "../../pages/upload/UploadPage"; + +export default function UploadTab() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 26fd95a..3bd1a8f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,11 @@ import NetInfo from "@react-native-community/netinfo"; import { onlineManager, QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import { queryClient } from "../shared/api/query-client"; +import { AuthProvider } from "../features/auth/model/AuthContext"; +import "../global.css"; onlineManager.setEventListener(setOnline => { return NetInfo.addEventListener(state => { @@ -9,12 +13,24 @@ onlineManager.setEventListener(setOnline => { }); }); +export { default as styled } from "nativewind"; + export default function RootLayout() { return ( - - - - - + + + + + + {/* 인증 관련 스크린 */} + + + + {/* 메인 앱 스크린 (탭 네비게이션) */} + + + + + ); } diff --git a/app/index.tsx b/app/index.tsx index 9dbfd26..0e74ba1 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,11 +1,31 @@ -import { SafeAreaView, ScrollView, Text } from "react-native"; +/*eslint-disable */ +import React, { useEffect } from "react"; +import { useRouter } from "expo-router"; +import { useAuth } from "../features/auth/model/AuthContext"; +import { View, ActivityIndicator } from "react-native"; export default function App() { + const router = useRouter(); + const { userInfo } = useAuth(); + + useEffect(() => { + const timer = setTimeout(() => { + if (userInfo) { + // 로그인된 사용자면, 홈 네비게이션으로 이동 + router.replace("/(tabs)/home"); + } else { + // 로그인되지 않았으면 로그인 페이지로 이동 + router.replace("/login"); + } + }, 100); + + return () => clearTimeout(timer); + }, [userInfo, router]); + + // 로딩 화면 표시. TODO: 추후에 디자인 된 로딩스피너로 수정 return ( - - - App.tsx to start working on your app! - - + + + ); } diff --git a/app/login.tsx b/app/login.tsx new file mode 100644 index 0000000..0e42abd --- /dev/null +++ b/app/login.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import LoginPage from "../pages/auth/LoginPage"; + +export default function Login() { + return ; +} diff --git a/app/login/index.tsx b/app/login/index.tsx deleted file mode 100644 index c8ce499..0000000 --- a/app/login/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { View, Text } from "react-native"; - -export default function Login() { - return ( - - Login - - ); -} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..c680675 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,19 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + 'nativewind/babel', + [ + 'module-resolver', + { + root: ['./'], + extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'], + alias: { + '@': './', + }, + }, + ], + ], + }; +}; diff --git a/features/auth/hooks/useLogin.ts b/features/auth/hooks/useLogin.ts deleted file mode 100644 index 7c44769..0000000 --- a/features/auth/hooks/useLogin.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function useLogin() { - // @TODO: login -} diff --git a/features/auth/model/AuthContext.tsx b/features/auth/model/AuthContext.tsx new file mode 100644 index 0000000..d2a7b83 --- /dev/null +++ b/features/auth/model/AuthContext.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; +import React, { createContext, useContext, useState } from "react"; + +// 사용자 정보 타입 정의 +interface UserInfo { + id: number; + email: string; + nickname: string; + profileImage?: string; + accessToken: string; +} + +interface AuthContextType { + userInfo: UserInfo | null; // 현재 로그인된 사용자 정보 + login: (userInfo: UserInfo) => void; // 로그인 함수 + logout: () => void; // 로그아웃 함수 + isLoggedIn: boolean; // 로그인 상태 확인 +} + +// React Context 생성 (초기값은 undefined) +const AuthContext = createContext(undefined); + +// 인증 컨텍스트를 사용하기 위한 커스텀 훅 +export const useAuth = () => { + const context = useContext(AuthContext); + // AuthProvider 외부에서 사용하려고 하면 에러 발생 + if (context === undefined) { + throw new Error("useAuth must be used within AuthProvider"); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +// 인증 상태를 관리하는 Provider 컴포넌트 +export const AuthProvider: React.FC = ({ children }) => { + // 사용자 정보 상태 관리 (null이면 로그아웃 상태) + const [userInfo, setUserInfo] = useState(null); + + const login = (userInfo: UserInfo) => { + setUserInfo(userInfo); + }; + + const logout = () => { + setUserInfo(null); + }; + + const isLoggedIn = !!userInfo; + + // Context Provider로 하위 컴포넌트들에게 인증 상태와 함수들 제공 + return ( + + {children} + + ); +}; diff --git a/features/auth/model/types.ts b/features/auth/model/types.ts new file mode 100644 index 0000000..761da86 --- /dev/null +++ b/features/auth/model/types.ts @@ -0,0 +1,14 @@ +export interface UserInfo { + id: number; + email: string; + nickname: string; + profileImage?: string; + accessToken: string; +} + +export interface SocialLoginProps { + onLoginSuccess: (userInfo: UserInfo) => void; + onLoginError: (error: string) => void; +} + +export type SocialProvider = "kakao" | "naver" | "google"; diff --git a/features/auth/ui/GoogleLoginButton.tsx b/features/auth/ui/GoogleLoginButton.tsx new file mode 100644 index 0000000..b1b0ede --- /dev/null +++ b/features/auth/ui/GoogleLoginButton.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { TouchableOpacity, Text } from "react-native"; +import type { SocialLoginProps } from "../model/types"; + +export const GoogleLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { + return ( + + 구글로 시작하기 + + ); +}; diff --git a/features/auth/ui/KakaoLoginButton.tsx b/features/auth/ui/KakaoLoginButton.tsx new file mode 100644 index 0000000..0aa3fad --- /dev/null +++ b/features/auth/ui/KakaoLoginButton.tsx @@ -0,0 +1,47 @@ +import React, { useState } from "react"; +import { TouchableOpacity, Text, Image, Modal } from "react-native"; +import { KakaoLoginWebView } from "./KakaoLoginWebView"; +import type { SocialLoginProps } from "../model/types"; + +export const KakaoLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { + const [showWebView, setShowWebView] = useState(false); + + const handleKakaoLogin = () => { + setShowWebView(true); + }; + + const handleLoginSuccess = (userInfo: any) => { + console.log("카카오 로그인 성공:", userInfo); + setShowWebView(false); + onLoginSuccess(userInfo); + }; + + const handleLoginError = (error: any) => { + console.error("카카오 로그인 실패:", error); + setShowWebView(false); + onLoginError(typeof error === "string" ? error : "카카오 로그인 중 오류가 발생했습니다."); + }; + + const handleClose = () => { + setShowWebView(false); + }; + + return ( + <> + + 카카오로 시작하기 + + + + + + + ); +}; diff --git a/features/auth/ui/KakaoLoginWebView.tsx b/features/auth/ui/KakaoLoginWebView.tsx new file mode 100644 index 0000000..8429c87 --- /dev/null +++ b/features/auth/ui/KakaoLoginWebView.tsx @@ -0,0 +1,137 @@ +import React, { useState } from "react"; +import { ActivityIndicator, Alert, Platform, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { WebViewNavigation } from "react-native-webview"; +import WebView from "react-native-webview"; +import axios from "axios"; + +// kakao developer 환경변수에서 카카오 API 키와 서버 URL을 가져옴 +const KAKAO_REST_API_KEY = process.env.EXPO_PUBLIC_KAKAO_REST_API_KEY; +const SERVER_BASE_URL = process.env.EXPO_PUBLIC_SERVER_BASE_URL; +const REDIRECT_URI = `${SERVER_BASE_URL}/api/auth/login`; + +interface KakaoLoginWebViewProps { + onLoginSuccess: (userInfo: any) => void; + onLoginError: (error: any) => void; + onClose: () => void; +} + +export function KakaoLoginWebView({ + onLoginSuccess, + onLoginError, + onClose, +}: KakaoLoginWebViewProps) { + const [isLoading, setIsLoading] = useState(false); + const [isChangeNavigate, setIsChangeNavigate] = useState(true); + + // 카카오 인증 코드를 받아서 액세스 토큰으로 교환하고 사용자 정보를 가져오는 함수 + const requestToken = async (code: string) => { + try { + setIsLoading(true); + + // 1단계: 인증 코드를 액세스 토큰으로 교환 + const tokenResponse = await axios({ + method: "POST", + url: "https://kauth.kakao.com/oauth/token", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: new URLSearchParams({ + grant_type: "authorization_code", + client_id: KAKAO_REST_API_KEY!, + redirect_uri: REDIRECT_URI, + code, + }), + }); + + console.log("토큰 응답:", tokenResponse.data); + const kakaoAccessToken = tokenResponse.data.access_token; + + // 2단계: 액세스 토큰으로 카카오 사용자 정보 조회 + const userResponse = await axios({ + method: "GET", + headers: { + Authorization: `Bearer ${kakaoAccessToken}`, + }, + url: "https://kapi.kakao.com/v2/user/me", + }); + + console.log("user 응답:", userResponse.data); + + // 받아온 사용자 정보를 앱에서 사용할 형태로 변환 + const userInfo = { + id: userResponse.data.id, + email: userResponse.data.kakao_account?.email, + nickname: userResponse.data.kakao_account?.profile?.nickname, + profileImage: userResponse.data.kakao_account?.profile?.profile_image_url, + accessToken: kakaoAccessToken, + }; + + onLoginSuccess(userInfo); + } catch (error) { + console.error("카카오 로그인 에러:", error); + onLoginError(error); + Alert.alert("로그인 실패", "카카오 로그인 중 오류가 발생했습니다.", [ + { text: "확인", onPress: onClose }, + ]); + } finally { + setIsLoading(false); + } + }; + + // 웹뷰의 URL이 변경될 때마다 호출되는 함수 + const handleNavigationChangeState = (event: WebViewNavigation) => { + console.log("Navigation state change:", event.url); + + // 리다이렉트 URL에 인증 코드가 포함되어 있는지 확인 + if (event.url.includes(`${REDIRECT_URI}?code=`)) { + const urlParams = new URLSearchParams(event.url.split("?")[1]); + const code = urlParams.get("code"); + + if (code) { + console.log("Authorization code received:", code); + setIsLoading(true); + // 인증 코드를 카카오에서 받아서, 토큰 교환 함수 호출 + requestToken(code); + return; + } + } + + setIsChangeNavigate(event.loading); + }; + + return ( + + {(isLoading || isChangeNavigate) && ( + + + + )} + + + ); +} diff --git a/features/auth/ui/NaverLoginButton.tsx b/features/auth/ui/NaverLoginButton.tsx new file mode 100644 index 0000000..c5029e5 --- /dev/null +++ b/features/auth/ui/NaverLoginButton.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { TouchableOpacity, Text } from "react-native"; +import type { SocialLoginProps } from "../model/types"; + +export const NaverLoginButton: React.FC = ({ onLoginSuccess, onLoginError }) => { + return ( + + 네이버로 시작하기 + + ); +}; diff --git a/features/auth/ui/SocialLoginButtons.tsx b/features/auth/ui/SocialLoginButtons.tsx index dbaabce..815c810 100644 --- a/features/auth/ui/SocialLoginButtons.tsx +++ b/features/auth/ui/SocialLoginButtons.tsx @@ -1,9 +1,24 @@ -import { View, Button } from "react-native"; +import React from "react"; +import { View } from "react-native"; +import { KakaoLoginButton } from "./KakaoLoginButton"; +import { NaverLoginButton } from "./NaverLoginButton"; +import { GoogleLoginButton } from "./GoogleLoginButton"; +import type { SocialLoginProps } from "../model/types"; -export function SocialLoginButtons() { +export const SocialLoginButtons: React.FC = ({ + onLoginSuccess, + onLoginError, +}) => { return ( - -