diff --git a/.npmrc b/.npmrc index ed8ff3f3..3ae12f5a 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,4 @@ @team-ppointer:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${NPM_TOKEN} \ No newline at end of file +//npm.pkg.github.com/:_authToken=${NPM_TOKEN} + +node-linker=hoisted \ No newline at end of file diff --git a/apps/admin/src/routes/_GNBLayout/qna/index.tsx b/apps/admin/src/routes/_GNBLayout/qna/index.tsx index b5bc4693..21f3ea38 100644 --- a/apps/admin/src/routes/_GNBLayout/qna/index.tsx +++ b/apps/admin/src/routes/_GNBLayout/qna/index.tsx @@ -458,7 +458,7 @@ const MessageBubble = ({ ); case 'text': default: - return

{content}

; + return

{content}

; } }; @@ -502,7 +502,7 @@ const MessageBubble = ({ )} {/* Message Content */} -
+
{!isMe && showProfile && senderName && (

{senderName}

)} diff --git a/apps/native/.gitignore b/apps/native/.gitignore index 722fb5d4..e47fd401 100644 --- a/apps/native/.gitignore +++ b/apps/native/.gitignore @@ -44,3 +44,7 @@ app-example # generated native folders /ios /android + +# Firebase Google Services +google-services.json +GoogleService-Info.plist \ No newline at end of file diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 92c5b468..445bf9f5 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -1,5 +1,5 @@ import 'react-native-gesture-handler'; -import React from 'react'; +import React, { useState } from 'react'; import { NavigationContainer, DefaultTheme, Theme } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; import { SafeAreaProvider } from 'react-native-safe-area-context'; @@ -8,11 +8,17 @@ import RootNavigator from '@navigation/RootNavigator'; import { colors } from '@theme/tokens'; import '@/app/providers/global.css'; import '@/app/providers/api'; -import { LoadingScreen } from '@components/common'; -import { useLoadAssets } from '@hooks'; +import { CustomSplashScreen } from '@/features/splash/screens/SplashScreen'; +import { useLoadAssets, useDeepLinkHandler } from '@hooks'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { Text, TextInput } from 'react-native'; import Toast from 'react-native-toast-message'; import { toastConfig } from '@/features/student/scrap/components/Notification/Toast'; +import { env } from '@utils'; +import { initializeKakaoSDK } from '@react-native-kakao/core'; +import { navigationRef } from '@/services/navigation'; + +initializeKakaoSDK(env.kakaoNativeAppKey); const queryClient = new QueryClient(); @@ -25,31 +31,39 @@ const navigationTheme: Theme = { }, }; -const linking = { - prefixes: ['pointer://', 'http://localhost:3000'], - config: { - screens: { - AuthCallback: 'auth/callback', - }, - }, -}; +if ((Text as any).defaultProps == null) (Text as any).defaultProps = {}; +(Text as any).defaultProps.allowFontScaling = false; +(Text as any).defaultProps.style = [{ fontFamily: 'Pretendard' }]; + +if ((TextInput as any).defaultProps == null) (TextInput as any).defaultProps = {}; +(TextInput as any).defaultProps.allowFontScaling = false; +(TextInput as any).defaultProps.style = [{ fontFamily: 'Pretendard' }]; export default function App() { - const { loading } = useLoadAssets(); + const { isReady } = useLoadAssets(); + const [isSplashAnimationFinished, setIsSplashAnimationFinished] = useState(false); - if (loading) { - return ; - } + // FCM 푸시 알림 딥링크 핸들러 + useDeepLinkHandler(); return ( - - - - - + {isReady && ( + + + + + + )} + + {!isSplashAnimationFinished && ( + setIsSplashAnimationFinished(true)} + /> + )} diff --git a/apps/native/app.config.ts b/apps/native/app.config.ts index 29b6cf52..535dc328 100644 --- a/apps/native/app.config.ts +++ b/apps/native/app.config.ts @@ -1,6 +1,55 @@ import type { ExpoConfig } from 'expo/config'; +import { withDangerousMod, type ConfigPlugin } from 'expo/config-plugins'; +import * as fs from 'fs'; +import * as path from 'path'; import 'dotenv/config'; +/** + * Custom Expo Config Plugin to enforce modular headers for Firebase dependencies. + * This fixes the "Module 'FirebaseCore' not found" error by ensuring critical Firebase pods + * use modular headers, allowing Swift/Obj-C interop without global useFrameworks: 'static'. + */ +const withFirebaseModularHeaders: ConfigPlugin = (config) => { + return withDangerousMod(config, [ + 'ios', + async (config) => { + const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile'); + + if (!fs.existsSync(podfilePath)) { + return config; + } + + let podfileContent = await fs.promises.readFile(podfilePath, 'utf-8'); + + // Force modular headers for key Firebase pods + // We inject this just before `use_react_native!` to ensure it overrides or sits alongside Expo's definitions + const modularHeadersPatch = ` + pod 'FirebaseCore', :modular_headers => true + pod 'FirebaseMessaging', :modular_headers => true + pod 'GoogleUtilities', :modular_headers => true +`; + + if (!podfileContent.includes("pod 'FirebaseCore', :modular_headers => true")) { + podfileContent = podfileContent.replace( + /use_react_native!/g, + `${modularHeadersPatch}\n use_react_native!` + ); + } + + await fs.promises.writeFile(podfilePath, podfileContent); + return config; + }, + ]); +}; + +const androidGoogleServicesFile = + process.env.ANDROID_GOOGLE_SERVICES_JSON || './google-services.json'; + +const iosGoogleServicesFile = process.env.IOS_GOOGLE_SERVICES_PLIST || './GoogleService-Info.plist'; + +const isDev = + process.env.APP_VARIANT === 'development' || process.env.EAS_BUILD_PROFILE === 'development'; + const config: ExpoConfig = { name: 'Pointer', slug: 'pointer', @@ -13,9 +62,16 @@ const config: ExpoConfig = { ios: { bundleIdentifier: 'com.math-pointer.pointer', supportsTablet: true, + usesAppleSignIn: true, + infoPlist: { + ITSAppUsesNonExemptEncryption: false, + UIBackgroundModes: ['remote-notification'], + }, + googleServicesFile: iosGoogleServicesFile, + icon: './assets/ios-pointer.icon', }, android: { - package: 'com.math-pointer.pointer', + package: 'com.math_pointer.pointer', adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/android-icon-foreground.png', @@ -24,11 +80,23 @@ const config: ExpoConfig = { }, edgeToEdgeEnabled: true, predictiveBackGestureEnabled: false, + googleServicesFile: androidGoogleServicesFile, }, web: { bundler: 'metro', }, plugins: [ + [ + 'expo-build-properties', + { + ios: { + deploymentTarget: '15.1', + }, + android: { + extraMavenRepos: ['https://devrepo.kakao.com/nexus/content/groups/public/'], + }, + }, + ], [ 'expo-splash-screen', { @@ -41,16 +109,43 @@ const config: ExpoConfig = { }, }, ], + [ + '@react-native-google-signin/google-signin', + { + iosUrlScheme: 'com.googleusercontent.apps.743865706187-4aj7gacd57ucldfarm5ton9ko9tm044l', + }, + ], + [ + '@react-native-kakao/core', + { + nativeAppKey: process.env.KAKAO_NATIVE_APP_KEY, + android: { + authCodeHandlerActivity: true, + }, + ios: { + handleKakaoOpenUrl: true, + }, + }, + ], + ['expo-apple-authentication'], + 'expo-notifications', + '@react-native-firebase/app', ], extra: { apiBaseUrl: process.env.NATIVE_API_BASE_URL, authRedirectUri: process.env.NATIVE_AUTH_REDIRECT_URI, devAccessToken: process.env.NATIVE_DEV_ACCESS_TOKEN, devRefreshToken: process.env.NATIVE_DEV_REFRESH_TOKEN, + googleWebClientId: process.env.GOOGLE_WEB_CLIENT_ID, + googleIosClientId: process.env.GOOGLE_IOS_CLIENT_ID, + kakaoNativeAppKey: process.env.KAKAO_NATIVE_APP_KEY, + eas: { + projectId: '76a68921-8c65-4e50-98b0-fb5ef457ab7e', + }, }, experiments: { reactCompiler: true, }, }; -export default config; +export default withFirebaseModularHeaders(config); diff --git a/apps/native/assets/ios-pointer.icon/Assets/inner.svg b/apps/native/assets/ios-pointer.icon/Assets/inner.svg new file mode 100644 index 00000000..b30cdae7 --- /dev/null +++ b/apps/native/assets/ios-pointer.icon/Assets/inner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/native/assets/ios-pointer.icon/Assets/outer.svg b/apps/native/assets/ios-pointer.icon/Assets/outer.svg new file mode 100644 index 00000000..724dcff6 --- /dev/null +++ b/apps/native/assets/ios-pointer.icon/Assets/outer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/native/assets/ios-pointer.icon/icon.json b/apps/native/assets/ios-pointer.icon/icon.json new file mode 100644 index 00000000..2a4af23c --- /dev/null +++ b/apps/native/assets/ios-pointer.icon/icon.json @@ -0,0 +1,40 @@ +{ + "fill" : { + "automatic-gradient" : "display-p3:0.32157,0.41961,0.91765,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "outer.svg", + "name" : "outer" + }, + { + "image-name" : "inner.svg", + "name" : "inner" + } + ], + "position" : { + "scale" : 1.3, + "translation-in-points" : [ + 15, + 0 + ] + }, + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/apps/native/docs/INTERACTION_GUIDELINES.md b/apps/native/docs/INTERACTION_GUIDELINES.md new file mode 100644 index 00000000..9d29ea28 --- /dev/null +++ b/apps/native/docs/INTERACTION_GUIDELINES.md @@ -0,0 +1,492 @@ +# 인터랙션 및 트랜지션 가이드라인 + +이 문서는 Pointer 앱의 버튼 및 인터랙티브 컴포넌트에 적용된 애니메이션과 트랜지션에 대한 상세 +가이드를 제공합니다. + +--- + +## 목차 + +1. [사용 가능한 컴포넌트](#1-사용-가능한-컴포넌트) +2. [버튼 Press 인터랙션](#2-버튼-press-인터랙션) +3. [토글 상태 트랜지션](#3-토글-상태-트랜지션) +4. [애니메이션 파라미터 레퍼런스](#4-애니메이션-파라미터-레퍼런스) +5. [구현 패턴](#5-구현-패턴) +6. [주의사항](#6-주의사항) + +--- + +## 1. 사용 가능한 컴포넌트 + +프로젝트에서 일관된 인터랙션을 적용하기 위해 제공되는 컴포넌트들입니다. + +### 1.1 AnimatedPressable + +가장 기본적인 애니메이션 Pressable 컴포넌트입니다. `Pressable`을 대체하여 사용할 수 있습니다. + +**경로**: `@components/common/AnimatedPressable` + +**Props**: + +| Prop | 타입 | 설명 | +| ------------------- | -------------------------------------------------- | ------------------------------------------- | +| `containerStyle` | `StyleProp` | 외부 컨테이너 스타일 (flex 등) | +| `animatedStyle` | `Animated.WithAnimatedValue>` | 애니메이션 스타일 (backgroundColor 등) | +| `disableScale` | `boolean` | Scale 애니메이션 비활성화 (리스트 아이템용) | +| `...PressableProps` | - | 기본 Pressable props 모두 지원 | + +**사용 예시**: + +```tsx +import { AnimatedPressable } from '@components/common'; + +// 기본 사용 + + 버튼 + + +// 토글 애니메이션과 함께 사용 + + + + +// 리스트 아이템에서 사용 (scale 없이 opacity만) + + 메뉴 아이템 + +``` + +### 1.2 BottomActionBar.Button + +하단 액션바 전용 버튼 컴포넌트입니다. `AnimatedPressable`과 동일한 인터랙션이 적용되어 있습니다. + +**경로**: `@features/student/problem/components/BottomActionBar` + +**Props**: | Prop | 타입 | 설명 | |------|------|------| | `className` | `string` | NativeWind +클래스 | | `containerStyle` | `StyleProp` | 외부 컨테이너 스타일 | | `animatedStyle` | +`Animated.WithAnimatedValue>` | 애니메이션 스타일 | | `...PressableProps` | - | +기본 Pressable props 모두 지원 | + +**사용 예시**: + +```tsx + + + 버튼 + + +``` + +### 1.3 컴포넌트 선택 가이드 + +| 상황 | 권장 컴포넌트 | +| ------------------------- | ------------------------------------- | +| 일반적인 버튼 | `AnimatedPressable` | +| 하단 액션바 내 버튼 | `BottomActionBar.Button` | +| BottomSheet 내 버튼 | `AnimatedPressable` | +| 키패드/숫자 버튼 | `AnimatedPressable` | +| 토글 버튼 (스크랩 등) | `AnimatedPressable` + `animatedStyle` | +| 탭 바 아이템 | `AnimatedTabItem` (내장) | +| 리스트 아이템 | `AnimatedPressable` + `disableScale` | +| 아이콘 버튼 (뒤로가기 등) | `AnimatedPressable` | + +### 1.4 탭 바 인터랙션 (MainTabBar) + +탭 바의 각 아이템에 press 애니메이션이 적용되어 있습니다. + +**애니메이션 스펙**: | 속성 | Press In | Press Out | | --- | --- | --- | | **Scale** | `0.9` | `1` | +| **애니메이션** | spring | spring | | **tension** | `300` | `300` | | **friction** | `10` | `10` | + +**구현 위치**: `@navigation/student/components/MainTabBar.tsx` + +### 1.5 화면 전환 애니메이션 (Tab Navigation) + +현재 탭 간 이동 시 애니메이션은 적용하지 않습니다. + +> ⚠️ **참고**: React Navigation 7의 Bottom Tab Navigator에서 `animation: 'fade'` 등의 전환 +> 애니메이션을 사용하면 lazy loading과 함께 사용 시 간헐적으로 화면이 표시되지 않는 문제가 발생할 수 +> 있어 현재는 애니메이션을 사용하지 않습니다. + +### 1.6 리스트 아이템 인터랙션 + +리스트 아이템(MenuListItem, IconMenuItem 등)은 scale 애니메이션이 어색할 수 있어, opacity만 +적용합니다. + +**Props 설정**: + +```tsx + + {' '} + {/* scale 애니메이션 비활성화 */} + 리스트 아이템 + +``` + +**애니메이션 스펙**: + +| 속성 | Press In | Press Out | +| ------------------ | -------- | --------- | +| **Scale** | 없음 | 없음 | +| **Opacity** | `0.7` | `1` | +| **duration (in)** | `100ms` | - | +| **duration (out)** | - | `150ms` | + +**적용 대상**: + +- `MenuListItem`: 메뉴 목록 아이템 +- `IconMenuItem`: 아이콘이 있는 메뉴 아이템 +- 기타 리스트 형태의 터치 가능한 요소 + +--- + +## 2. 버튼 Press 인터랙션 + +버튼을 누를 때 자연스러운 피드백을 제공하는 애니메이션입니다. + +### 2.1 Scale 애니메이션 + +| 속성 | Press In | Press Out | +| ------------------- | -------- | --------- | +| **toValue** | `0.95` | `1` | +| **애니메이션 타입** | `spring` | `spring` | +| **tension** | `300` | `300` | +| **friction** | `10` | `10` | +| **useNativeDriver** | `true` | `true` | + +### 2.2 Opacity 애니메이션 + +| 속성 | Press In | Press Out | +| ------------------- | -------- | --------- | +| **toValue** | `0.7` | `1` | +| **애니메이션 타입** | `timing` | `timing` | +| **duration** | `100ms` | `150ms` | +| **useNativeDriver** | `true` | `true` | + +### 2.3 구현 예시 + +```tsx +import { useRef } from 'react'; +import { Animated, Pressable } from 'react-native'; + +const AnimatedButton = ({ children, onPress }) => { + const scaleAnim = useRef(new Animated.Value(1)).current; + const opacityAnim = useRef(new Animated.Value(1)).current; + + const handlePressIn = () => { + Animated.parallel([ + Animated.spring(scaleAnim, { + toValue: 0.95, + useNativeDriver: true, + tension: 300, + friction: 10, + }), + Animated.timing(opacityAnim, { + toValue: 0.7, + duration: 100, + useNativeDriver: true, + }), + ]).start(); + }; + + const handlePressOut = () => { + Animated.parallel([ + Animated.spring(scaleAnim, { + toValue: 1, + useNativeDriver: true, + tension: 300, + friction: 10, + }), + Animated.timing(opacityAnim, { + toValue: 1, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + }; + + return ( + + + {children} + + + ); +}; +``` + +--- + +## 3. 토글 상태 트랜지션 + +토글 버튼(스크랩, 좋아요 등)의 상태 변경 시 적용되는 애니메이션입니다. + +### 3.1 배경색 트랜지션 + +| 속성 | 값 | +| ------------------- | ---------------------------------------------------- | +| **애니메이션 타입** | `spring` | +| **tension** | `200` | +| **friction** | `20` | +| **useNativeDriver** | `false` (색상 애니메이션은 네이티브 드라이버 미지원) | + +#### 스크랩 버튼 색상 예시 + +| 상태 | 배경색 | 아이콘 Stroke | 아이콘 Fill | +| ---------- | ---------------------- | ------------------------- | ------------------------- | +| **비활성** | `gray-200` (`#F3F5FB`) | `gray-700` (`#6B6F77`) | `transparent` | +| **활성** | `gray-400` (`#DFE2E7`) | `primary-500` (`#617AF9`) | `primary-500` (`#617AF9`) | + +### 3.2 Interpolation 설정 + +```tsx +const animValue = useRef(new Animated.Value(0)).current; + +// 배경색 interpolation +const backgroundColor = animValue.interpolate({ + inputRange: [0, 1], + outputRange: [colors['gray-200'], colors['gray-400']], +}); + +// 아이콘 색상 interpolation (필요시) +const iconColor = animValue.interpolate({ + inputRange: [0, 1], + outputRange: [colors['gray-700'], colors['primary-500']], +}); +``` + +### 3.3 Optimistic Update 패턴 + +서버 응답을 기다리지 않고 즉시 UI를 업데이트하고, 에러 시에만 롤백합니다. + +```tsx +const handleToggle = useCallback(() => { + if (mutation.isPending) return; + + const previousState = isActive; + const newState = !previousState; + + // 1. 즉시 상태 업데이트 + setIsActive(newState); + + // 2. 애니메이션 시작 + Animated.spring(animValue, { + toValue: newState ? 1 : 0, + useNativeDriver: false, + tension: 200, + friction: 20, + }).start(); + + // 3. API 호출 + mutation.mutate(request, { + onError: () => { + // 4. 에러 시 롤백 + setIsActive(previousState); + Animated.spring(animValue, { + toValue: previousState ? 1 : 0, + useNativeDriver: false, + tension: 200, + friction: 20, + }).start(); + Alert.alert('실패', '다시 시도해주세요.'); + }, + }); +}, [isActive, animValue, mutation]); +``` + +--- + +## 4. 애니메이션 파라미터 레퍼런스 + +### 4.1 Spring 애니메이션 + +| 용도 | tension | friction | 특성 | +| -------------- | ------- | -------- | --------------- | +| **버튼 Press** | `300` | `10` | 빠르고 탄력적 | +| **상태 토글** | `200` | `20` | 부드럽고 안정적 | + +#### Spring 파라미터 설명 + +- **tension**: 스프링의 강도. 높을수록 빠르게 목표값에 도달 +- **friction**: 마찰력. 높을수록 오버슈팅이 줄어듦 + +### 4.2 Timing 애니메이션 + +| 용도 | duration | 특성 | +| --------------------- | -------- | --------------- | +| **Press In Opacity** | `100ms` | 빠른 피드백 | +| **Press Out Opacity** | `150ms` | 자연스러운 복귀 | + +### 4.3 useNativeDriver 사용 기준 + +| 속성 | useNativeDriver | +| -------------------------------------- | --------------- | +| `transform` (scale, rotate, translate) | ✅ `true` | +| `opacity` | ✅ `true` | +| `backgroundColor` | ❌ `false` | +| `width`, `height` | ❌ `false` | +| `borderRadius` | ❌ `false` | + +--- + +## 5. 구현 패턴 + +### 5.1 Native/Non-Native 애니메이션 분리 + +`useNativeDriver: true`와 `false`를 같은 `Animated.View`에 적용하면 에러가 발생합니다. 반드시 별도의 +`Animated.View`로 분리해야 합니다. + +```tsx +// ✅ 올바른 패턴 +const AnimatedButton = ({ animatedStyle, children }) => { + const scaleAnim = useRef(new Animated.Value(1)).current; + const opacityAnim = useRef(new Animated.Value(1)).current; + + // Inner: Native driver (scale, opacity) + const innerContent = ( + + {children} + + ); + + // Outer: Non-native driver (backgroundColor 등) + if (animatedStyle) { + return ( + + {innerContent} + + ); + } + + return innerContent; +}; +``` + +### 5.2 Flex 레이아웃과 Animated.View + +`flex-1` 같은 레이아웃 속성은 가장 바깥 컨테이너에 적용해야 합니다. + +```tsx +// Props에 containerStyle 추가 +type ButtonProps = { + containerStyle?: StyleProp; + // ... +}; + +// 사용 예시 +