From f5b3aac8a7e44c9b4f740e68c8e1a870c61e93f9 Mon Sep 17 00:00:00 2001 From: peisonger Date: Thu, 4 Jun 2026 10:56:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat=20:=20=EA=B8=B0=EB=A1=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=8F=99=EC=A0=81=20=EC=A7=80=EB=8F=84=20=EB=B0=8F?= =?UTF-8?q?=20=EB=82=A0=EC=A7=9C=20=EB=B0=B0=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하드코딩 SVG 지도 → NaverMapView + GPS 경로(노란 선) + 사진·출발·도착 마커로 교체 - HikingRecordDetail 타입에 track(GeoJSON), photos 필드 추가 - 날짜 배지 디자인 피그마 스펙 반영 (배경색, 날짜/시간 텍스트 분리) - CourseBottomSheet → hikingRecordId 올바르게 전달하도록 버그 수정 - 포토 리포트 오버레이 3열 우측 여백 부족 수정 --- app/record/[id].tsx | 266 ++++++++++-- app/record/photo-report-edit.tsx | 381 +++++------------- components/bottom-sheet.tsx | 8 +- components/course-bottom-sheet.tsx | 4 +- .../home/hooks/use-hiking-record-detail.ts | 12 + features/photo-report/overlay-stats.tsx | 343 ++++++++++++++++ features/photo-report/photo-report-state.ts | 2 + 7 files changed, 702 insertions(+), 314 deletions(-) create mode 100644 features/photo-report/overlay-stats.tsx diff --git a/app/record/[id].tsx b/app/record/[id].tsx index cff8d3cf..a9072b84 100644 --- a/app/record/[id].tsx +++ b/app/record/[id].tsx @@ -1,4 +1,20 @@ import Clive1Svg from "@/assets/clive1.svg"; +import { + NaverMapMarkerOverlay, + NaverMapPathOverlay, + NaverMapView, + type NaverMapViewRef, +} from "@mj-studio/react-native-naver-map"; +import { + OVERLAY_COMPONENTS, + fmtDistance, + fmtCalories, + fmtElevation, + fmtWeather, + fmtDuration, + fmtDate, + type OverlayProps, +} from "@/features/photo-report/overlay-stats"; import { CliveBottomBar } from "@/components/clive-bottom-bar"; import { CheckCircleIcon } from "@/components/icons/check-circle-icon"; import { ChevronLeftIcon } from "@/components/icons/chevron-left-icon"; @@ -7,7 +23,7 @@ import { XIcon } from "@/components/icons/x-icon"; import { useHikingRecordDetail } from "@/features/home/hooks/use-hiking-record-detail"; import { useToggleSemofeedPublic } from "@/features/home/hooks/use-toggle-semofeed-public"; import { useHikingSummary } from "@/features/mypage/hooks/use-hiking-summary"; -import { getPhotoReportState } from "@/features/photo-report/photo-report-state"; +import { getPhotoReportState, setPhotoReportState } from "@/features/photo-report/photo-report-state"; import { useClivePhotos } from "@/features/tracking/hooks/use-clive-photos"; import { uploadImage } from "@/hooks/use-upload-image"; import { api } from "@/lib/api"; @@ -38,6 +54,35 @@ import Svg, { import ViewShot from "react-native-view-shot"; const ALTITUDE_LABELS = ["400m", "800m", "1200m", "1600m"]; const CLIVE_CARD_HEIGHT = 596; +const DAY_KO = ["일", "월", "화", "수", "목", "금", "토"]; + +function parseTrack(track?: string): { latitude: number; longitude: number }[] { + if (!track) return []; + try { + const geo = JSON.parse(track); + if (geo.type === "LineString" && Array.isArray(geo.coordinates)) { + return geo.coordinates.map(([lng, lat]: [number, number]) => ({ + latitude: lat, + longitude: lng, + })); + } + } catch {} + return []; +} + +function formatMapDateParts(startedAt?: string, endedAt?: string): { date: string; time: string } { + if (!startedAt) return { date: "", time: "" }; + const d = new Date(startedAt); + const yy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const day = DAY_KO[d.getDay()]; + const startTime = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + if (!endedAt) return { date: `${yy}.${mm}.${dd} ${day}`, time: startTime }; + const e = new Date(endedAt); + const endTime = `${String(e.getHours()).padStart(2, "0")}:${String(e.getMinutes()).padStart(2, "0")}`; + return { date: `${yy}.${mm}.${dd} ${day}`, time: `${startTime} - ${endTime}` }; +} function formatDuration(seconds: number | null): string { if (seconds == null) return "--"; @@ -49,11 +94,6 @@ function formatDuration(seconds: number | null): string { } const PhotoReportBg = require("@/assets/photo-report-bg.png"); -const OVERLAY_STATS = [ - require("@/assets/overlay-stats-1.png"), - require("@/assets/overlay-stats-2.png"), - require("@/assets/overlay-stats-3.png"), -]; type RecordTab = "클라이브" | "포토 리포트"; export default function RecordScreen() { @@ -125,7 +165,9 @@ export default function RecordScreen() { const displayPhotos = [...clivePhotos].reverse(); const cliveShotRef = useRef(null); const photoReportShotRef = useRef(null); + const mapRef = useRef(null); const activeTabPublic = isPublicByTab[activeTab]; + const trackCoords = parseTrack(recordDetail?.track); const captureCard = async (tab: RecordTab) => { const targetRef = tab === "클라이브" ? cliveShotRef : photoReportShotRef; @@ -164,12 +206,36 @@ export default function RecordScreen() { useFocusEffect( useCallback(() => { - const { photoSource, templateIndex } = getPhotoReportState(); - if (photoSource !== null) setPhotoReportSource(photoSource); - setPhotoReportTemplate(templateIndex); - }, []), + const saved = getPhotoReportState(); + // 같은 세션의 상태만 복원 — 다른 기록의 사진이 유입되지 않도록 + if (saved.sessionId === sessionId && saved.photoSource !== null) { + setPhotoReportSource(saved.photoSource); + } + if (saved.sessionId === sessionId) { + setPhotoReportTemplate(saved.templateIndex); + } + }, [sessionId]), ); + // 경로 로드 후 카메라 fit + useEffect(() => { + if (trackCoords.length < 2) return; + const lats = trackCoords.map((c) => c.latitude); + const lngs = trackCoords.map((c) => c.longitude); + const minLat = Math.min(...lats); + const maxLat = Math.max(...lats); + const minLng = Math.min(...lngs); + const maxLng = Math.max(...lngs); + const padding = 0.15; + setTimeout(() => { + mapRef.current?.animateCameraWithTwoCoords({ + coord1: { latitude: minLat - (maxLat - minLat) * padding, longitude: minLng - (maxLng - minLng) * padding }, + coord2: { latitude: maxLat + (maxLat - minLat) * padding, longitude: maxLng + (maxLng - minLng) * padding }, + duration: 300, + }); + }, 300); + }, [trackCoords.length]); + // 클라이브 사진 프리페치 + 포토리포트 기본 사진 = 마지막 사진(정상) useEffect(() => { if (clivePhotos.length === 0) return; @@ -290,19 +356,85 @@ export default function RecordScreen() { {/* 루트 지도 */} - - {typeof Clive1Svg === "number" ? ( - + + {trackCoords.length > 1 ? ( + + {/* 흰색 베이스 (테두리 효과) */} + + {/* 노란 경로 */} + + + {/* 출발 마커 (파란 원) */} + + + + + {/* 도착 마커 (빨간 원) */} + + + + + {/* 사진 마커 */} + {(recordDetail?.photos ?? []).map((photo) => ( + + + + + + ))} + ) : ( - + // track 없으면 기존 하드코딩 이미지 폴백 + typeof Clive1Svg === "number" ? ( + + ) : ( + + ) )} + {/* 날짜/시간 배지 */} + + {(() => { + const { date, time } = formatMapDateParts(recordDetail?.startedAt, recordDetail?.endedAt); + return ( + + {date || "--"} + {!!time && {" " + time}} + + ); + })()} + {/* 통계 */} @@ -567,14 +699,19 @@ export default function RecordScreen() { /> {/* 스탯 오버레이 */} - + {(() => { + const OverlayComponent = OVERLAY_COMPONENTS[photoReportTemplate] ?? OVERLAY_COMPONENTS[0]; + const overlayProps: OverlayProps = { + distance: fmtDistance(recordDetail?.distanceMeters), + calories: fmtCalories(recordDetail?.calories), + elevation: fmtElevation(recordDetail?.ascentMeters), + weather: fmtWeather(recordDetail?.temperature), + duration: fmtDuration(recordDetail?.durationSeconds), + date: fmtDate(recordDetail?.startedAt), + scale: 335 / 240, + }; + return ; + })()} @@ -585,12 +722,21 @@ export default function RecordScreen() { { position: "absolute", top: 16, right: 16 }, ]} activeOpacity={0.7} - onPress={() => + onPress={() => { + // 현재 표시 중인 사진/템플릿을 상태에 저장 → 편집 화면에서 이어받음 + setPhotoReportState({ + sessionId, + photoSource: photoReportSource, + templateIndex: photoReportTemplate, + }); router.push({ pathname: "/record/photo-report-edit", - params: { sessionId: String(sessionId ?? "") }, - }) - } + params: { + sessionId: String(sessionId ?? ""), + hikingRecordId: String(hikingRecordIdNum ?? ""), + }, + }); + }} > 편집하기 @@ -732,6 +878,32 @@ const styles = StyleSheet.create({ width: "100%", height: 235, }, + dateBadgeWrap: { + position: "absolute", + bottom: 14, + left: 0, + right: 0, + alignItems: "center", + zIndex: 10, + }, + dateBadge: { + backgroundColor: "#464A57", + borderRadius: 999, + paddingHorizontal: 16, + paddingVertical: 6, + flexDirection: "row", + alignItems: "center", + }, + dateBadgeDateText: { + color: "#FFFFFF", + fontSize: 13, + fontWeight: "500", + }, + dateBadgeTimeText: { + color: "#E5E7EB", + fontSize: 13, + fontWeight: "500", + }, statItem: { flex: 1, alignItems: "center", @@ -836,4 +1008,32 @@ const styles = StyleSheet.create({ textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 13, }, + dotMarkerBlue: { + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: "#507EF4", + borderWidth: 2, + borderColor: "#FFFFFF", + }, + dotMarkerRed: { + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: "#FF5249", + borderWidth: 2, + borderColor: "#FFFFFF", + }, + photoMarkerWrap: { + width: 44, + height: 44, + borderRadius: 8, + overflow: "hidden", + borderWidth: 2, + borderColor: "#FFFFFF", + }, + photoMarkerImg: { + width: 40, + height: 40, + }, }); diff --git a/app/record/photo-report-edit.tsx b/app/record/photo-report-edit.tsx index 754a6b91..0a780735 100644 --- a/app/record/photo-report-edit.tsx +++ b/app/record/photo-report-edit.tsx @@ -3,7 +3,19 @@ import { Image as ExpoImage } from "expo-image"; import * as ImagePicker from "expo-image-picker"; import { useRef, useState, useEffect } from "react"; import { useClivePhotos } from "@/features/tracking/hooks/use-clive-photos"; +import { useHikingRecordDetail } from "@/features/home/hooks/use-hiking-record-detail"; import { setPhotoReportState } from "@/features/photo-report/photo-report-state"; +import { + OVERLAY_COMPONENTS, + fmtDistance, + fmtCalories, + fmtElevation, + fmtWeather, + fmtDuration, + fmtDate, + type OverlayProps, +} from "@/features/photo-report/overlay-stats"; +import { getPhotoReportState } from "@/features/photo-report/photo-report-state"; import { Dimensions, Modal, @@ -34,17 +46,7 @@ const NAV_BTN_TOP = 12 + (CARD_H - NAV_BTN_SIZE) / 2; const THUMB_SIZE = 85; -const OVERLAY_TEMPLATES = [ - { id: 1 }, - { id: 2 }, - { id: 3 }, -]; - -const OVERLAY_STATS = [ - require("@/assets/overlay-stats-1.png"), - require("@/assets/overlay-stats-2.png"), - require("@/assets/overlay-stats-3.png"), -]; +const OVERLAY_TEMPLATES = [{ id: 1 }, { id: 2 }, { id: 3 }]; const PhotoReportBg = require("@/assets/photo-report-bg.png"); @@ -52,30 +54,44 @@ type Photo = { key: string; source: number | { uri: string } }; export default function PhotoReportEditScreen() { const router = useRouter(); - const { sessionId } = useLocalSearchParams<{ sessionId?: string }>(); + const { sessionId, hikingRecordId } = useLocalSearchParams<{ + sessionId?: string; + hikingRecordId?: string; + }>(); const parsedSessionId = sessionId ? parseInt(sessionId) : null; + const parsedHikingRecordId = hikingRecordId ? parseInt(hikingRecordId) : null; + const { data: trackingPhotos = [] } = useClivePhotos(parsedSessionId); + const { data: recordDetail } = useHikingRecordDetail(parsedHikingRecordId); const { top, bottom } = useSafeAreaInsets(); - const [selectedTemplate, setSelectedTemplate] = useState(0); - const [photos, setPhotos] = useState([]); - const [selectedPhoto, setSelectedPhoto] = useState(null); - // 트래킹 사진을 기본 목록으로 설정 (로드 완료 후 1회) + // 이전 화면에서 저장한 사진/템플릿 상태로 초기화 + const savedState = getPhotoReportState(); + const [selectedTemplate, setSelectedTemplate] = useState(savedState.templateIndex ?? 0); + const [photos, setPhotos] = useState(() => { + const src = savedState.photoSource; + if (src != null) { + const key = typeof src === "number" ? String(src) : src.uri; + return [{ key, source: src }]; + } + return []; + }); + const [selectedPhoto, setSelectedPhoto] = useState( + savedState.photoSource != null ? 0 : null, + ); + const [showExitDialog, setShowExitDialog] = useState(false); + const scrollRef = useRef(null); + useEffect(() => { if (trackingPhotos.length > 0 && photos.length === 0) { setPhotos(trackingPhotos.map((uri) => ({ key: uri, source: { uri } }))); setSelectedPhoto(0); } }, [trackingPhotos]); - const [showExitDialog, setShowExitDialog] = useState(false); - const scrollRef = useRef(null); const scrollTo = (index: number) => { - scrollRef.current?.scrollTo({ - x: index * (CARD_W + CARD_GAP), - animated: true, - }); + scrollRef.current?.scrollTo({ x: index * (CARD_W + CARD_GAP), animated: true }); }; const handleLeft = () => { @@ -94,35 +110,33 @@ export default function PhotoReportEditScreen() { } }; - const handleTemplatePress = (index: number) => { - setSelectedTemplate(index); - scrollTo(index); - }; - const handleImport = async () => { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== "granted") return; - const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, quality: 1, orderedSelection: true, }); - if (!result.canceled && result.assets.length > 0) { - const newPhotos: Photo[] = result.assets.map((a) => ({ - key: a.uri, - source: { uri: a.uri }, - })); + const newPhotos: Photo[] = result.assets.map((a) => ({ key: a.uri, source: { uri: a.uri } })); setPhotos((prev) => [...newPhotos, ...prev]); setSelectedPhoto(0); } }; + const overlayProps: OverlayProps = { + distance: fmtDistance(recordDetail?.distanceMeters), + calories: fmtCalories(recordDetail?.calories), + elevation: fmtElevation(recordDetail?.ascentMeters), + weather: fmtWeather(recordDetail?.temperature), + duration: fmtDuration(recordDetail?.durationSeconds), + date: fmtDate(recordDetail?.startedAt), + }; + return ( - {/* 헤더 */} router.back()} hitSlop={8}> @@ -134,7 +148,6 @@ export default function PhotoReportEditScreen() { - {/* 템플릿 카드 + 좌우 버튼 */} - {OVERLAY_TEMPLATES.map((template, index) => ( - handleTemplatePress(index)} - style={{ opacity: selectedTemplate === index ? 1 : 0.4 }} - > - - - - - - ))} + {OVERLAY_TEMPLATES.map((template, index) => { + const OverlayComponent = OVERLAY_COMPONENTS[index]; + return ( + { setSelectedTemplate(index); scrollTo(index); }} + style={{ opacity: selectedTemplate === index ? 1 : 0.4 }} + > + + + + + + ); + })} - {/* 왼쪽 버튼 */} {selectedTemplate > 0 && ( - + )} - - {/* 오른쪽 버튼 */} {selectedTemplate < OVERLAY_TEMPLATES.length - 1 && ( - + )} - {/* 사진 선택 행 */} - {/* 가져오기 */} - + 가져오기 - - {/* 사진 썸네일 */} {photos.map((photo, index) => ( - setSelectedPhoto(index)} - activeOpacity={0.8} - > - - - {selectedPhoto === index && ( - - )} + setSelectedPhoto(index)} activeOpacity={0.8}> + + + {selectedPhoto === index && } ))} - {/* 편집 종료 확인 다이얼로그 */} - setShowExitDialog(false)} - > + setShowExitDialog(false)}> setShowExitDialog(false)}> @@ -264,18 +227,10 @@ export default function PhotoReportEditScreen() { 편집을 그만할까요? - setShowExitDialog(false)} - > + setShowExitDialog(false)}> 계속 편집 - router.back()} - > + router.back()}> 그만하기 @@ -285,13 +240,13 @@ export default function PhotoReportEditScreen() { - {/* 완료 버튼 */} { const photo = selectedPhoto !== null ? photos[selectedPhoto] : null; setPhotoReportState({ + sessionId: parsedSessionId, photoSource: photo ? photo.source : null, templateIndex: selectedTemplate, }); @@ -307,25 +262,10 @@ export default function PhotoReportEditScreen() { } const styles = StyleSheet.create({ - header: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - height: 56, - paddingHorizontal: 20, - }, - templateContainer: { - position: "relative", - }, - templateScroll: { - flexGrow: 0, - }, - templateCard: { - width: CARD_W, - height: CARD_H, - borderRadius: 16, - overflow: "hidden", - }, + header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", height: 56, paddingHorizontal: 20 }, + templateContainer: { position: "relative" }, + templateScroll: { flexGrow: 0 }, + templateCard: { width: CARD_W, height: CARD_H, borderRadius: 16, overflow: "hidden" }, navBtn: { position: "absolute", width: NAV_BTN_SIZE, @@ -334,136 +274,27 @@ const styles = StyleSheet.create({ backgroundColor: "#FFFFFF", alignItems: "center", justifyContent: "center", - boxShadow: '0px 2px 4px 0px rgba(0, 0, 0, 0.1)', - }, - photoRowScroll: { - flexGrow: 0, - marginTop: 24, - }, - photoRow: { - flexDirection: "row", - alignItems: "center", - paddingLeft: 20, - paddingRight: 8, - gap: 8, - }, - importBtn: { - width: THUMB_SIZE, - height: THUMB_SIZE, - borderRadius: 8, - borderWidth: 1, - borderColor: "#D1D5DB", - alignItems: "center", - justifyContent: "center", - gap: 2, - }, - importLabel: { - fontSize: 12, - fontWeight: "500", - color: "#73798C", - }, - thumbWrap: { - width: THUMB_SIZE, - height: THUMB_SIZE, - borderRadius: 8, - overflow: "hidden", - }, - thumb: { - width: THUMB_SIZE, - height: THUMB_SIZE, - }, - selectOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - borderRadius: 8, - paddingTop: 4, - paddingRight: 4, - alignItems: "flex-end", - }, - selectOverlayActive: { - borderWidth: 2, - borderColor: "#00D864", - }, - selectOverlayInactive: { - borderWidth: 0, - }, - dialogOverlay: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.5)", - alignItems: "center", - justifyContent: "center", - }, - dialog: { - width: 320, - backgroundColor: "#FFFFFF", - borderRadius: 14, - overflow: "hidden", - }, - dialogContent: { - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: 12, - }, - dialogTitle: { - fontSize: 20, - fontWeight: "600", - color: "#000C1E", - }, - dialogBtnArea: { - flexDirection: "row", - gap: 8, - paddingHorizontal: 16, - paddingTop: 16, - paddingBottom: 20, - }, - dialogBtnCancel: { - flex: 1, - height: 48, - borderRadius: 10, - backgroundColor: "#F0F1F4", - alignItems: "center", - justifyContent: "center", - }, - dialogBtnCancelText: { - fontSize: 17, - fontWeight: "600", - color: "#464A57", - }, - dialogBtnConfirm: { - flex: 1, - height: 48, - borderRadius: 10, - backgroundColor: "#1A1B1F", - alignItems: "center", - justifyContent: "center", - }, - dialogBtnConfirmText: { - fontSize: 17, - fontWeight: "600", - color: "#FFFFFF", - }, - bottomBar: { - position: "absolute", - bottom: 0, - left: 0, - right: 0, - backgroundColor: "#FFFFFF", - paddingTop: 16, - paddingHorizontal: 20, - }, - doneBtn: { - height: 56, - backgroundColor: "#1A1B1F", - borderRadius: 12, - alignItems: "center", - justifyContent: "center", - }, - doneBtnText: { - fontSize: 17, - fontWeight: "600", - color: "#FFFFFF", + boxShadow: "0px 2px 4px 0px rgba(0, 0, 0, 0.1)", }, + photoRowScroll: { flexGrow: 0, marginTop: 24 }, + photoRow: { flexDirection: "row", alignItems: "center", paddingLeft: 20, paddingRight: 8, gap: 8 }, + importBtn: { width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: 8, borderWidth: 1, borderColor: "#D1D5DB", alignItems: "center", justifyContent: "center", gap: 2 }, + importLabel: { fontSize: 12, fontWeight: "500", color: "#73798C" }, + thumbWrap: { width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: 8, overflow: "hidden" }, + thumb: { width: THUMB_SIZE, height: THUMB_SIZE }, + selectOverlay: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, borderRadius: 8, paddingTop: 4, paddingRight: 4, alignItems: "flex-end" }, + selectOverlayActive: { borderWidth: 2, borderColor: "#00D864" }, + selectOverlayInactive: { borderWidth: 0 }, + dialogOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", alignItems: "center", justifyContent: "center" }, + dialog: { width: 320, backgroundColor: "#FFFFFF", borderRadius: 14, overflow: "hidden" }, + dialogContent: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 12 }, + dialogTitle: { fontSize: 20, fontWeight: "600", color: "#000C1E" }, + dialogBtnArea: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingTop: 16, paddingBottom: 20 }, + dialogBtnCancel: { flex: 1, height: 48, borderRadius: 10, backgroundColor: "#F0F1F4", alignItems: "center", justifyContent: "center" }, + dialogBtnCancelText: { fontSize: 17, fontWeight: "600", color: "#464A57" }, + dialogBtnConfirm: { flex: 1, height: 48, borderRadius: 10, backgroundColor: "#1A1B1F", alignItems: "center", justifyContent: "center" }, + dialogBtnConfirmText: { fontSize: 17, fontWeight: "600", color: "#FFFFFF" }, + bottomBar: { position: "absolute", bottom: 0, left: 0, right: 0, backgroundColor: "#FFFFFF", paddingTop: 16, paddingHorizontal: 20 }, + doneBtn: { height: 56, backgroundColor: "#1A1B1F", borderRadius: 12, alignItems: "center", justifyContent: "center" }, + doneBtnText: { fontSize: 17, fontWeight: "600", color: "#FFFFFF" }, }); diff --git a/components/bottom-sheet.tsx b/components/bottom-sheet.tsx index bcba956c..75ea7298 100644 --- a/components/bottom-sheet.tsx +++ b/components/bottom-sheet.tsx @@ -77,15 +77,15 @@ export default function BottomSheet({ { + onCoursePress={(hikingRecordId) => { const record = mountainRecords.find( - (r) => r.hikingRecordId === courseId, + (r) => r.hikingRecordId === hikingRecordId, ); router.push({ pathname: "/record/[id]", params: { - id: String(record?.sessionId ?? courseId), - hikingRecordId: String(record?.hikingRecordId ?? ""), + id: String(record?.sessionId ?? hikingRecordId), + hikingRecordId: String(hikingRecordId ?? ""), name: selectedCard.mountainName, courseName: record?.courseName ?? "", imageUri: "", diff --git a/components/course-bottom-sheet.tsx b/components/course-bottom-sheet.tsx index 4826ac2a..a706dc9e 100644 --- a/components/course-bottom-sheet.tsx +++ b/components/course-bottom-sheet.tsx @@ -56,7 +56,7 @@ export const MOCK_COURSES: Course[] = [ type Props = { courses: GetUserHikingRecordResponse[]; mountainId?: number; - onCoursePress?: (courseId?: number) => void; + onCoursePress?: (hikingRecordId?: number) => void; }; export default function CourseBottomSheet({ @@ -71,7 +71,7 @@ export default function CourseBottomSheet({ key={course.hikingRecordId} course={course} mountainId={mountainId} - onPress={() => onCoursePress?.(course.courseId)} + onPress={() => onCoursePress?.(course.hikingRecordId)} /> ))} diff --git a/features/home/hooks/use-hiking-record-detail.ts b/features/home/hooks/use-hiking-record-detail.ts index c99a4ca4..4ec668a6 100644 --- a/features/home/hooks/use-hiking-record-detail.ts +++ b/features/home/hooks/use-hiking-record-detail.ts @@ -1,6 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; +export type RecordPhotoMarker = { + milestoneIndex: number; + lat: number; + lng: number; + imageUrl: string; + capturedAt: string; +}; + export type HikingRecordDetail = { hikingRecordId: number; distanceMeters?: number; @@ -11,6 +19,9 @@ export type HikingRecordDetail = { calories?: number; startedAt?: string; endedAt?: string; + temperature?: number; + track?: string; // GeoJSON LineString + photos?: RecordPhotoMarker[]; }; export function useHikingRecordDetail(hikingRecordId: number | null) { @@ -21,6 +32,7 @@ export function useHikingRecordDetail(hikingRecordId: number | null) { const res = await api.get({ path: `/api/hiking-records/${hikingRecordId}`, }); + console.log("[HikingRecordDetail]", JSON.stringify(res.data, null, 2)); return res.data; }, }); diff --git a/features/photo-report/overlay-stats.tsx b/features/photo-report/overlay-stats.tsx new file mode 100644 index 00000000..e39e5f25 --- /dev/null +++ b/features/photo-report/overlay-stats.tsx @@ -0,0 +1,343 @@ +import SemosanLogoSvg from "@/assets/semosan-logo.svg"; +import { LinearGradient } from "expo-linear-gradient"; +import { StyleSheet, Text, View } from "react-native"; +import Svg, { Defs, LinearGradient as SvgGradient, Path, Stop } from "react-native-svg"; + +// ─── 데이터 포맷 헬퍼 ────────────────────────────────────────────────────────── + +export function fmtDistance(meters?: number): string { + if (meters == null) return "--"; + return (meters / 1000).toFixed(2); +} + +export function fmtCalories(kcal?: number): string { + if (kcal == null) return "--"; + return String(kcal); +} + +export function fmtElevation(meters?: number): string { + if (meters == null) return "--"; + return String(Math.round(meters)); +} + +export function fmtWeather(temp?: number): string { + if (temp == null) return "--"; + return `${Math.round(temp)}°`; +} + +export function fmtDuration(seconds?: number): string { + if (seconds == null) return "--"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +export function fmtDate(iso?: string): string { + if (!iso) return "--"; + const d = new Date(iso); + const yy = String(d.getFullYear()).slice(2); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yy}.${mm}.${dd}`; +} + +// ─── 오버레이 props ─────────────────────────────────────────────────────────── + +export type OverlayProps = { + distance: string; + calories: string; + elevation: string; + weather: string; + duration: string; + date: string; + // Figma 기준 카드: 240×426. 다른 크기 카드에서는 scale 전달 (e.g. 335/240 ≈ 1.4) + scale?: number; +}; + +// ─── 산 실루엣 SVG (Figma 하드코딩) ───────────────────────────────────────── +// viewBox="0 0 186 44", Figma 기준 position: x=28, y=30 + +const MTN_MULT = 1.15; // 산 SVG 크기 배율 + +function MountainFrame({ scale = 1 }: { scale?: number }) { + const w = 186 * scale * MTN_MULT; + const h = 44 * scale * MTN_MULT; + return ( + + + + + + + + + + + + + + + + ); +} + +// ─── 통계 아이템 ────────────────────────────────────────────────────────────── + +// 폰트 크기 배율 — Figma 원본(3/13/6)에서 시각적으로 키운 값 +const LABEL_FS = 4.2; +const VALUE_FS = 17.5; +const UNIT_FS = 8; + +function StatItem({ label, value, unit, scale = 1 }: { label: string; value: string; unit?: string; scale?: number }) { + return ( + + + {label} + + + + {value} + + {unit ? ( + + {unit} + + ) : null} + + + ); +} + +// ─── 공통 배경 그라디언트 ───────────────────────────────────────────────────── + +function OverlayBackground() { + return ( + + ); +} + +// ─── 템플릿 1 ───────────────────────────────────────────────────────────────── +// Figma 240×426 기준 좌표. scale=335/240≈1.4 로 전달하면 335×596 카드에 맞게 확장됨 + +export function Template1Overlay({ distance, calories, elevation, weather, duration, date, scale = 1 }: OverlayProps) { + const s = scale; + // 열 시작 x (Figma: 26, 95, 158) + const col1x = s * 26; + const col2x = s * 95; + const col3x = s * 158; + // 그리드 시작 y (Figma: 88) + const gridY = s * 88; + // 로고 (Figma: x=159, y=402) + const logoX = s * 159; + const logoY = s * 402; + + return ( + + + + + {/* 열 1: DISTANCE / CALORIES */} + + + + + + {/* 열 2: ELEVATION / WEATHER */} + + + + + + {/* 열 3: DURATION / DATE */} + + + + + + {/* 세모산 로고 */} + + + + + ); +} + +// ─── 템플릿 2 산 실루엣 (83×29 viewBox, Figma 하드코딩) ────────────────────── + +function MountainFrameSmall({ scale = 1 }: { scale?: number }) { + const w = 83 * scale * MTN_MULT; + const h = 29 * scale * MTN_MULT; + return ( + + + + + + + + + + + + + + + + ); +} + +// ─── 템플릿 2 ───────────────────────────────────────────────────────────────── +// Stats 하단 배치 (y≈308), 그라디언트 하단 어둡게, 로고 좌상단 + +export function Template2Overlay({ distance, calories, elevation, weather, duration, date, scale = 1 }: OverlayProps) { + const s = scale; + // Figma 기준 절대 좌표 (카드 x=7770, y=-250.5 기준) + const col1x = s * 24.5; // x=7794.5-7770 + const col2x = s * 76.5; // x=7846.5-7770 + const datex = s * 160.5; // x=7930.5-7770 + const row1y = s * 308.5; // y=58.0-(-250.5) + const row2y = s * 342.5; // y=92.0-(-250.5) + const row3y = s * 376.5; // y=126.0-(-250.5) + + return ( + + {/* 하단 그라디언트 (stats가 아래에 있으므로 역방향) */} + + + {/* 세모산 로고 — 좌상단 (x=13.5, y=16.5) */} + + + + + {/* 작은 산 실루엣 */} + + + {/* 행 1: DISTANCE | DURATION */} + + + + + + + + {/* 행 2: ELEVATION | CALORIES */} + + + + + + + + {/* 행 3: WEATHER | (STEPS 빈칸) | DATE */} + + + + + + + + ); +} + +// ─── 템플릿 3 산 실루엣 (85×30 viewBox, Figma 하드코딩) ────────────────────── + +function MountainFrame3({ scale = 1 }: { scale?: number }) { + const w = 85 * scale * MTN_MULT; + const h = 30 * scale * MTN_MULT; + return ( + + + + + + + + + + + + + + + + ); +} + +// ─── 템플릿 3 ───────────────────────────────────────────────────────────────── +// Stats 중앙 배치 (y≈72), 2열 × 3행, 그라디언트 상단 어둡게, 로고 우하단 + +export function Template3Overlay({ distance, calories, elevation, weather, duration, date, scale = 1 }: OverlayProps) { + const s = scale; + // Figma 기준 절대 좌표 (카드 x=8195.5, y=-250.5 기준) + const col1x = s * 25; // x=8220.5-8195.5 + const col2x = s * 77; // x=8272.5-8195.5 + const row1y = s * 72; // y=-178.5-(-250.5) + const row2y = s * 106; // y=-144.5-(-250.5) + const row3y = s * 140; // y=-110.5-(-250.5) + + return ( + + + + + {/* 행 1: DISTANCE | DURATION */} + + + + + + + + {/* 행 2: ELEVATION | CALORIES */} + + + + + + + + {/* 행 3: WEATHER | DATE */} + + + + + + + + {/* 세모산 로고 — 우하단 (x=159, y=402) */} + + + + + ); +} + +export const OVERLAY_COMPONENTS = [Template1Overlay, Template2Overlay, Template3Overlay]; diff --git a/features/photo-report/photo-report-state.ts b/features/photo-report/photo-report-state.ts index eb996b2b..4820e460 100644 --- a/features/photo-report/photo-report-state.ts +++ b/features/photo-report/photo-report-state.ts @@ -1,9 +1,11 @@ type PhotoReportState = { + sessionId: number | null; photoSource: number | { uri: string } | null; templateIndex: number; }; let state: PhotoReportState = { + sessionId: null, photoSource: null, templateIndex: 0, }; From 4a4b91c9a685a0bddf0f6560d3ec967576442e18 Mon Sep 17 00:00:00 2001 From: peisonger Date: Fri, 5 Jun 2026 02:50:05 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat=20:=20=EA=B8=B0=EB=A1=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A7=80=EB=8F=84=203=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EC=8A=A4=20=ED=8F=B4=EB=A6=AC=EB=9D=BC=EC=9D=B8=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GPS 트랙 없음 → 코스 폴리라인 + 시작/끝 마커 - GPS 트랙 있음, 사진 없음 → GPS 경로 + 시작/끝 마커 - GPS 트랙 있음, 사진 있음 → GPS 경로 + 시작/끝 마커 + 원형 사진 마커 - 정적 이미지 폴백(mapbasic.svg) 제거, NaverMapView 항상 렌더링 - bottom-sheet.tsx 네비게이션에 courseId 파라미터 추가 --- app/record/[id].tsx | 132 ++++++++++++++++++------------------ assets/mapbasic.svg | 72 ++++++++++++++++++++ components/bottom-sheet.tsx | 1 + 3 files changed, 139 insertions(+), 66 deletions(-) create mode 100644 assets/mapbasic.svg diff --git a/app/record/[id].tsx b/app/record/[id].tsx index a9072b84..9b00c0c6 100644 --- a/app/record/[id].tsx +++ b/app/record/[id].tsx @@ -1,4 +1,3 @@ -import Clive1Svg from "@/assets/clive1.svg"; import { NaverMapMarkerOverlay, NaverMapPathOverlay, @@ -23,6 +22,9 @@ import { XIcon } from "@/components/icons/x-icon"; import { useHikingRecordDetail } from "@/features/home/hooks/use-hiking-record-detail"; import { useToggleSemofeedPublic } from "@/features/home/hooks/use-toggle-semofeed-public"; import { useHikingSummary } from "@/features/mypage/hooks/use-hiking-summary"; +import { useCourseDetail } from "@/features/tracking/hooks/use-course-detail"; +import { parseCoursePolyline } from "@/features/tracking/utils/parse-course-polyline"; +import { getCenterCoordinate } from "@/utils/get-center-coordinate"; import { getPhotoReportState, setPhotoReportState } from "@/features/photo-report/photo-report-state"; import { useClivePhotos } from "@/features/tracking/hooks/use-clive-photos"; import { uploadImage } from "@/hooks/use-upload-image"; @@ -97,18 +99,20 @@ const PhotoReportBg = require("@/assets/photo-report-bg.png"); type RecordTab = "클라이브" | "포토 리포트"; export default function RecordScreen() { - const { id, hikingRecordId, name, courseName, imageUri, distance, duration } = + const { id, hikingRecordId, name, courseName, courseId, imageUri, distance, duration } = useLocalSearchParams<{ id: string; hikingRecordId?: string; name: string; courseName?: string; + courseId?: string; imageUri?: string; distance?: string; duration?: string; }>(); const sessionId = id ? parseInt(id) : null; const hikingRecordIdNum = hikingRecordId ? parseInt(hikingRecordId) : null; + const courseIdNum = courseId ? parseInt(courseId) : null; const distanceKm = distance ? parseFloat(distance) / 1000 : null; const durationSec = duration ? parseInt(duration) : null; const router = useRouter(); @@ -162,6 +166,7 @@ export default function RecordScreen() { const { data: clivePhotos = [] } = useClivePhotos(sessionId); const { data: hikingSummary } = useHikingSummary(); const { data: recordDetail } = useHikingRecordDetail(hikingRecordIdNum); + const { data: courseDetail } = useCourseDetail(courseIdNum); const displayPhotos = [...clivePhotos].reverse(); const cliveShotRef = useRef(null); const photoReportShotRef = useRef(null); @@ -357,72 +362,67 @@ export default function RecordScreen() { {/* 루트 지도 */} - {trackCoords.length > 1 ? ( - - {/* 흰색 베이스 (테두리 효과) */} - - {/* 노란 경로 */} - - - {/* 출발 마커 (파란 원) */} - - - + {(() => { + const hasTrack = trackCoords.length > 1; + const courseCoords = parseCoursePolyline(courseDetail?.polyline); + const courseCenter = getCenterCoordinate(courseCoords); + const mapCoords = hasTrack ? trackCoords : courseCoords; + const center = hasTrack + ? { latitude: trackCoords[Math.floor(trackCoords.length / 2)].latitude, longitude: trackCoords[Math.floor(trackCoords.length / 2)].longitude } + : courseCenter ?? { latitude: 37.5665, longitude: 126.9780 }; - {/* 도착 마커 (빨간 원) */} - - - - - {/* 사진 마커 */} - {(recordDetail?.photos ?? []).map((photo) => ( - - - - - - ))} - - ) : ( - // track 없으면 기존 하드코딩 이미지 폴백 - typeof Clive1Svg === "number" ? ( - - ) : ( - - ) - )} + {mapCoords.length > 1 && ( + <> + + + + + + + + + + )} + {hasTrack && (recordDetail?.photos ?? []).map((photo) => ( + + + + + + ))} + + ); + })()} {/* 날짜/시간 배지 */} {(() => { diff --git a/assets/mapbasic.svg b/assets/mapbasic.svg new file mode 100644 index 00000000..b0ceeec5 --- /dev/null +++ b/assets/mapbasic.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/bottom-sheet.tsx b/components/bottom-sheet.tsx index 75ea7298..d9b93514 100644 --- a/components/bottom-sheet.tsx +++ b/components/bottom-sheet.tsx @@ -88,6 +88,7 @@ export default function BottomSheet({ hikingRecordId: String(hikingRecordId ?? ""), name: selectedCard.mountainName, courseName: record?.courseName ?? "", + courseId: String(record?.courseId ?? ""), imageUri: "", distance: String(record?.distance ?? ""), duration: String(record?.duration ?? ""), From db2045d0b9016a2fd447ef9704ff79bf4a76f4ba Mon Sep 17 00:00:00 2001 From: peisonger Date: Fri, 5 Jun 2026 03:02:09 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix=20:=20=EA=B8=B0=EB=A1=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=8F=B4=EB=A6=AC=EB=9D=BC=EC=9D=B8=20=EA=B5=B5?= =?UTF-8?q?=EA=B8=B0=20=EC=BD=94=EC=8A=A4=20=EC=83=81=EC=84=B8=EC=99=80=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20(width=2012=E2=86=924)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/record/[id].tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/record/[id].tsx b/app/record/[id].tsx index 9b00c0c6..21bd462a 100644 --- a/app/record/[id].tsx +++ b/app/record/[id].tsx @@ -386,8 +386,7 @@ export default function RecordScreen() { > {mapCoords.length > 1 && ( <> - - + Date: Fri, 5 Jun 2026 03:42:38 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix=20:=20=EA=B8=B0=EB=A1=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A7=80=EB=8F=84=20=EC=82=AC=EC=A7=84=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EC=9B=90=ED=98=95=2024=C3=9724=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20zoom=20=EC=9E=90=EB=8F=99=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/record/[id].tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/app/record/[id].tsx b/app/record/[id].tsx index 21bd462a..9d207276 100644 --- a/app/record/[id].tsx +++ b/app/record/[id].tsx @@ -365,17 +365,24 @@ export default function RecordScreen() { {(() => { const hasTrack = trackCoords.length > 1; const courseCoords = parseCoursePolyline(courseDetail?.polyline); - const courseCenter = getCenterCoordinate(courseCoords); const mapCoords = hasTrack ? trackCoords : courseCoords; - const center = hasTrack - ? { latitude: trackCoords[Math.floor(trackCoords.length / 2)].latitude, longitude: trackCoords[Math.floor(trackCoords.length / 2)].longitude } - : courseCenter ?? { latitude: 37.5665, longitude: 126.9780 }; + const mapCenter = getCenterCoordinate(mapCoords); + const center = mapCenter ?? { latitude: 37.5665, longitude: 126.9780 }; + + const lats = mapCoords.map((c) => c.latitude); + const lngs = mapCoords.map((c) => c.longitude); + const latKm = (Math.max(...lats) - Math.min(...lats)) * 111; + const lngKm = (Math.max(...lngs) - Math.min(...lngs)) * 89; + const dominantRatio = Math.max(lngKm / 12, latKm / 7); + const zoom = mapCoords.length > 1 + ? Math.min(Math.max(Math.round(11 + Math.log2(0.6 / dominantRatio)), 6), 18) + : 13; return ( Date: Fri, 5 Jun 2026 05:36:13 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix=20:=20=ED=8F=AC=ED=86=A0=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=ED=8E=B8=EC=A7=91=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EC=83=81=ED=83=9C=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=ED=8A=B8=EB=9E=98=ED=82=B9=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EB=B3=91=ED=95=A9=20=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/record/photo-report-edit.tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/record/photo-report-edit.tsx b/app/record/photo-report-edit.tsx index 0a780735..c3da2585 100644 --- a/app/record/photo-report-edit.tsx +++ b/app/record/photo-report-edit.tsx @@ -66,11 +66,12 @@ export default function PhotoReportEditScreen() { const { top, bottom } = useSafeAreaInsets(); - // 이전 화면에서 저장한 사진/템플릿 상태로 초기화 + // 이전 화면에서 저장한 사진/템플릿 상태로 초기화 (같은 세션일 때만 복원) const savedState = getPhotoReportState(); - const [selectedTemplate, setSelectedTemplate] = useState(savedState.templateIndex ?? 0); + const isSameSession = savedState.sessionId === parsedSessionId; + const [selectedTemplate, setSelectedTemplate] = useState(isSameSession ? (savedState.templateIndex ?? 0) : 0); const [photos, setPhotos] = useState(() => { - const src = savedState.photoSource; + const src = isSameSession ? savedState.photoSource : null; if (src != null) { const key = typeof src === "number" ? String(src) : src.uri; return [{ key, source: src }]; @@ -78,15 +79,24 @@ export default function PhotoReportEditScreen() { return []; }); const [selectedPhoto, setSelectedPhoto] = useState( - savedState.photoSource != null ? 0 : null, + isSameSession && savedState.photoSource != null ? 0 : null, ); const [showExitDialog, setShowExitDialog] = useState(false); const scrollRef = useRef(null); useEffect(() => { - if (trackingPhotos.length > 0 && photos.length === 0) { - setPhotos(trackingPhotos.map((uri) => ({ key: uri, source: { uri } }))); - setSelectedPhoto(0); + if (trackingPhotos.length > 0) { + setPhotos((prev) => { + const existingKeys = new Set(prev.map((p) => p.key)); + const newPhotos = trackingPhotos + .filter((uri) => !existingKeys.has(uri)) + .map((uri) => ({ key: uri, source: { uri } })); + const merged = [...prev, ...newPhotos]; + if (selectedPhoto === null && merged.length > 0) { + setSelectedPhoto(0); + } + return merged; + }); } }, [trackingPhotos]);