Feature: 최종 기능 구현 및 QA#56
Conversation
… 로그 메시지 제거, RecordDetail에서 Container 컴포넌트 리팩토링
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Summary of ChangesHello @Monixc, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 Pull Request는 사용자 경험을 크게 향상시키는 여러 핵심 기능을 도입합니다. 런닝 기록을 상세히 확인하고, 나만의 인증 사진을 꾸며 공유할 수 있는 기능을 제공하며, 코스 런닝 중에는 실시간 음성 내비게이션으로 경로 안내를 받을 수 있습니다. 또한, 홈 화면의 시각적 요소를 강화하고, 전반적인 앱 성능과 안정성을 개선하기 위한 다양한 코드 최적화 및 디버깅 로깅 제거 작업이 포함되었습니다. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
이 PR은 인증사진 촬영 및 꾸미기, 런닝 기록 상세 조회, 턴바이턴 내비게이션 등 다양한 최종 기능들을 구현하고 있습니다. 전반적으로 코드 품질이 높고, 기능 구현이 체계적으로 이루어졌습니다. 특히 제스처 핸들링 리팩토링과 react-native-reanimated의 runOnJS를 활용한 성능 개선은 매우 훌륭합니다. 다만, 몇 가지 개선점을 제안합니다. 스타일 가이드에 따라 매직 넘버와 하드코딩된 문자열들을 상수로 분리하고, run.ts 스토어의 상태 초기화 로직에서 발견된 잠재적인 버그를 수정하는 것이 좋겠습니다. 또한, 일부 새로운 컴포넌트에서 하드코딩된 색상 값을 테마나 상수 파일로 옮겨 일관성을 높일 수 있습니다. 자세한 내용은 각 파일에 남긴 코멘트를 참고해주세요.
| // isTracking이 true면 일시정지 상태이므로 상태 보존 | ||
| if (state.isTracking) { |
There was a problem hiding this comment.
resetRunState 함수의 로직이 변경되어, isTracking이 false일 때 (즉, 런닝이 일시정지되었을 때) 런닝 상태가 완전히 초기화될 위험이 있습니다. 이전 로직처럼 state.startTime의 존재 여부로 런닝 진행 상태를 판단해야 합니다. 현재 로직은 사용자가 런닝을 잠시 멈추었을 때 기록이 사라지는 심각한 버그를 유발할 수 있습니다.
| // isTracking이 true면 일시정지 상태이므로 상태 보존 | |
| if (state.isTracking) { | |
| // 런닝이 진행 중(시작되었거나 일시정지 상태)이면 상태를 보존 | |
| if (state.startTime) { |
| stats: { | ||
| distance: runningStats.distance, | ||
| calories: runningStats.calories, | ||
| pace: parseFloat(runningStats.pace.replace(':', '.')), |
There was a problem hiding this comment.
parseFloat(runningStats.pace.replace(':', '.')) 로직은 페이스 값을 문자열에서 숫자로 다시 변환하는 과정이 불안정해 보입니다. runningStats.pace는 "5'30\""와 같은 형식의 문자열일 수 있어 replace(':', '.')가 의도대로 동작하지 않을 수 있습니다. formatRunningStats 함수에서 포맷팅되기 전의 원본 숫자 페이스 값(routeStats.pace)을 사용하는 것이 더 안전하고 명확합니다.
| pace: parseFloat(runningStats.pace.replace(':', '.')), | |
| pace: routeStats.pace, |
| NSCameraUsageDescription: | ||
| '인증사진 촬영을 위해 카메라 접근이 필요합니다.', | ||
| NSMicrophoneUsageDescription: | ||
| '비디오 촬영을 위해 마이크 접근이 필요합니다.', | ||
| NSPhotoLibraryUsageDescription: | ||
| '인증사진을 갤러리에 저장하기 위해 사진 라이브러리 접근이 필요합니다.', |
There was a problem hiding this comment.
권한 요청 문구가 여러 곳에서 중복으로 사용되고 있습니다. 예를 들어 '인증사진 촬영을 위해 카메라 접근이 필요합니다.' 문구가 NSCameraUsageDescription과 react-native-vision-camera 플러그인 설정에 모두 하드코딩되어 있습니다. 스타일 가이드(144라인)1에 따라, 이러한 문자열들을 별도의 상수 파일로 분리하여 관리하면 유지보수성과 코드의 일관성을 높일 수 있습니다.
Style Guide References
Footnotes
-
상수는 하드코딩하지 말고
constants.ts또는theme.ts에 관리해야 합니다. ↩
| title: '카메라 권한', | ||
| message: '사진 촬영을 위해 카메라 권한이 필요합니다.', | ||
| buttonNeutral: '나중에', | ||
| buttonNegative: '취소', | ||
| buttonPositive: '확인', |
| const starCount = Math.floor(Math.random() * 100) + 200; | ||
| const newStars: Star[] = []; | ||
|
|
||
| for (let i = 0; i < starCount; i++) { | ||
| let x, y; | ||
|
|
||
| if (Math.random() < 0.3) { | ||
| const lineProgress = Math.random(); | ||
| const lineWidth = 150; | ||
| const centerX = lineProgress * screenWidth; | ||
| const centerY = lineProgress * screenHeight * 0.7 + screenHeight * 0.15; | ||
|
|
||
| x = centerX + (Math.random() - 0.5) * lineWidth; | ||
| y = centerY + (Math.random() - 0.5) * lineWidth * 0.3; | ||
| } else { | ||
| x = Math.random() * screenWidth; | ||
| y = Math.random() * screenHeight; | ||
| } | ||
|
|
||
| newStars.push({ | ||
| id: i, | ||
| x: Math.max(0, Math.min(screenWidth, x)), | ||
| y: Math.max(0, Math.min(screenHeight, y)), | ||
| size: Math.random() * 1.7 + 0.3, | ||
| animatedValue: new Animated.Value(Math.random() * 0.6 + 0.3), | ||
| }); | ||
| } | ||
|
|
||
| setStars(newStars); | ||
|
|
||
| newStars.forEach((star) => { | ||
| const createTwinkleAnimation = () => { | ||
| const minOpacity = Math.random() * 0.2 + 0.1; | ||
| const maxOpacity = Math.random() * 0.4 + 0.6; | ||
|
|
||
| return Animated.sequence([ | ||
| Animated.timing(star.animatedValue, { | ||
| toValue: maxOpacity, | ||
| duration: Math.random() * 3000 + 2000, | ||
| useNativeDriver: true, | ||
| }), | ||
| Animated.timing(star.animatedValue, { | ||
| toValue: minOpacity, | ||
| duration: Math.random() * 3000 + 2000, | ||
| useNativeDriver: true, | ||
| }), | ||
| ]); | ||
| }; | ||
|
|
||
| const startAnimation = () => { | ||
| createTwinkleAnimation().start(() => { | ||
| setTimeout(() => { | ||
| startAnimation(); | ||
| }, Math.random() * 2000); | ||
| }); | ||
| }; | ||
|
|
||
| setTimeout(() => { | ||
| startAnimation(); | ||
| }, Math.random() * 5000); |
There was a problem hiding this comment.
컴포넌트 내부에 200, 100, 0.3, 150, 3000 등 여러 매직 넘버가 사용되었습니다. 스타일 가이드(145라인)1에서는 매직 넘버를 이름 있는 상수로 치환하도록 권장하고 있습니다. 각 숫자의 의미를 명확히 나타내는 상수로 정의하면 코드의 가독성과 유지보수성이 크게 향상됩니다.
예시:
const STAR_COUNT_BASE = 200;
const STAR_COUNT_RANDOM_FACTOR = 100;
const GALAXY_PROBABILITY = 0.3;Style Guide References
Footnotes
-
매직 넘버(예: 200, 0.5 등)는 이름 있는 상수로 치환해야 합니다. ↩
|
|
||
| type Props = { | ||
| navigation: NativeStackNavigationProp<TabParamList, 'RunTab'>; | ||
| navigation: any; // RootStackParamList와 TabParamList 모두 지원 |
There was a problem hiding this comment.
navigation prop의 타입을 any로 지정하셨습니다. 스타일 가이드(18라인)1에서는 any 타입 사용을 금지하고 있습니다. RunTabStackParamList와 TabParamList를 포함하는 더 구체적인 내비게이션 타입을 정의하거나 제네릭을 사용하여 타입을 명시하는 것이 좋습니다.
| navigation: any; // RootStackParamList와 TabParamList 모두 지원 | |
| navigation: NativeStackNavigationProp<any>; // RootStackParamList와 TabParamList 모두 지원 |
Style Guide References
Footnotes
-
any는 금지합니다. 불가피할 경우unknown을 우선 사용합니다. ↩
| // 인증 에러나 네트워크 에러 시 기본 이미지 사용 | ||
| const fallbackImages = [ | ||
| require('@/assets/bear.png'), | ||
| require('@/assets/chick.png'), | ||
| require('@/assets/dog.png'), | ||
| require('@/assets/woman.png'), | ||
| ]; | ||
|
|
||
| const newImages: FloatingImageData[] = []; | ||
| const maxAttempts = 500; | ||
|
|
||
| fallbackImages.forEach((image: any, index: number) => { | ||
| const count = 1; | ||
|
|
||
| for (let i = 0; i < count; i++) { | ||
| let attempts = 0; | ||
| let validPosition = false; | ||
| let newImage: FloatingImageData | null = null; | ||
|
|
||
| while (attempts < maxAttempts && !validPosition) { | ||
| newImage = { | ||
| id: index * 10 + i, | ||
| source: image, | ||
| x: Math.random() * (screenWidth - 100), | ||
| y: Math.random() * (screenHeight / 2 - 250) + 150, | ||
| size: Math.random() * 60 + 100, | ||
| rotation: Math.random() * 360, | ||
| }; | ||
|
|
||
| if (isValidPosition(newImage, newImages)) { | ||
| validPosition = true; | ||
| newImages.push(newImage); | ||
| } | ||
| attempts++; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| setFloatingImages(newImages); | ||
| setFloatingImages(newImages); | ||
| } |
| const finalPathData = routePathData || getDefaultPathData(); | ||
|
|
||
| if (!routePathData) { | ||
| console.log('📊 [RouteRenderer] routePathData가 없음, 테스트 경로 사용'); |
| const saveToGallery = async () => { | ||
| try { | ||
| // 미디어 라이브러리 권한 요청 | ||
| const { status } = await MediaLibrary.requestPermissionsAsync(); | ||
| if (status !== 'granted') { | ||
| Alert.alert('권한 필요', '갤러리에 저장하려면 권한이 필요합니다.'); | ||
| return false; | ||
| } | ||
|
|
||
| // 갤러리에 저장 | ||
| const asset = await MediaLibrary.createAssetAsync(photoUri); | ||
|
|
||
| return true; | ||
| } catch (error) { | ||
| return false; | ||
| } | ||
| }; |
| // 유턴은 더 강한 색상으로 표시 | ||
| if (instruction.scale === '유턴') { | ||
| return ['#f59e0b', '#d97706', '#b45309']; // 주황색 그라데이션 | ||
| } | ||
|
|
||
| return ['#3b82f6', '#2563eb', '#1d4ed8']; // 파란색 그라데이션 |
작업 내용
참고 사항