Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 243 additions & 37 deletions app/record/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
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";
Expand All @@ -7,7 +22,10 @@ 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 { 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";
import { api } from "@/lib/api";
Expand Down Expand Up @@ -38,6 +56,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}` };
}
Comment on lines +75 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

startedAt 또는 endedAt에 유효하지 않은 날짜 문자열이 전달될 경우, new Date()Invalid Date를 반환하며 이후 getFullYear(), getMonth() 등의 메서드가 NaN을 반환하여 화면에 "NaN.NaN.NaN undefined"와 같이 비정상적인 텍스트가 표시될 수 있습니다. 날짜가 유효한지 확인하는 방어 코드를 추가하는 것이 안전합니다.

function formatMapDateParts(startedAt?: string, endedAt?: string): { date: string; time: string } {
  if (!startedAt) return { date: "", time: "" };
  const d = new Date(startedAt);
  if (isNaN(d.getTime())) return { date: "", time: "" };
  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);
  if (isNaN(e.getTime())) return { date: `${yy}.${mm}.${dd} ${day}`, time: startTime };
  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 "--";
Expand All @@ -49,26 +96,23 @@ 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() {
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();
Expand Down Expand Up @@ -122,10 +166,13 @@ 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<ViewShot | null>(null);
const photoReportShotRef = useRef<ViewShot | null>(null);
const mapRef = useRef<NaverMapViewRef>(null);
const activeTabPublic = isPublicByTab[activeTab];
const trackCoords = parseTrack(recordDetail?.track);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이미 @/features/tracking/utils/parse-course-polyline에 동일한 GeoJSON LineString 파싱 로직을 수행하는 parseCoursePolyline 함수가 정의되어 있습니다. 중복 코드를 방지하기 위해 parseTrack 함수를 제거하고 parseCoursePolyline을 직접 재사용하는 것을 권장합니다.

Suggested change
const trackCoords = parseTrack(recordDetail?.track);
const trackCoords = parseCoursePolyline(recordDetail?.track);


const captureCard = async (tab: RecordTab) => {
const targetRef = tab === "클라이브" ? cliveShotRef : photoReportShotRef;
Expand Down Expand Up @@ -164,12 +211,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]);
Comment on lines +226 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

useEffect 내에서 setTimeout을 사용하여 카메라를 이동시키고 있습니다. 컴포넌트가 300ms 이내에 언마운트되거나 trackCoords.length가 변경되어 이펙트가 재실행될 때 타이머가 정리(cleanup)되지 않으면, 메모리 누수가 발생하거나 이미 언마운트된 컴포넌트의 ref에 접근하려는 경고가 발생할 수 있습니다. 반환 함수(cleanup function)에서 타이머를 클리어해 주어야 합니다.

Suggested change
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 (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;
const timer = 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);
return () => clearTimeout(timer);
}, [trackCoords.length]);


// 클라이브 사진 프리페치 + 포토리포트 기본 사진 = 마지막 사진(정상)
useEffect(() => {
if (clivePhotos.length === 0) return;
Expand Down Expand Up @@ -290,19 +361,86 @@ export default function RecordScreen() {
</View>

{/* 루트 지도 */}
<View
className="mx-5 overflow-hidden rounded-xl"
style={styles.mapContainer}
>
{typeof Clive1Svg === "number" ? (
<ExpoImage
source={Clive1Svg}
style={styles.mapImage}
contentFit="cover"
/>
) : (
<Clive1Svg width="100%" height="100%" />
)}
<View className="mx-5 overflow-hidden rounded-xl" style={styles.mapContainer}>
{(() => {
const hasTrack = trackCoords.length > 1;
const courseCoords = parseCoursePolyline(courseDetail?.polyline);
const mapCoords = hasTrack ? trackCoords : courseCoords;
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;
Comment on lines +372 to +379

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

mapCoords가 비어있을 때(예: 데이터 로딩 중이거나 경로 정보가 없을 때), Math.max(...lats)Math.min(...lats)는 각각 -InfinityInfinity를 반환합니다. 이로 인해 latKmlngKm-Infinity가 되고, dominantRatio 또한 -Infinity가 되어 Math.log2 계산 시 비정상적인 값이 발생합니다. 비록 삼항 연산자(mapCoords.length > 1)로 실제 zoom 값은 13으로 대체되지만, 불필요하고 위험한 수학적 연산이 무조건 실행되므로 해당 계산을 조건문 내부로 격리하는 것이 안전합니다.

Suggested change
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;
let zoom = 13;
if (mapCoords.length > 1) {
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);
zoom = Math.min(Math.max(Math.round(11 + Math.log2(0.6 / dominantRatio)), 6), 18);
}


return (
<NaverMapView
ref={mapRef}
style={StyleSheet.absoluteFill}
camera={{ ...center, zoom }}
isScrollGesturesEnabled={false}
isZoomGesturesEnabled={false}
isRotateGesturesEnabled={false}
isTiltGesturesEnabled={false}
isStopGesturesEnabled={false}
logoAlign="BottomLeft"
logoMargin={{ bottom: 4, left: 4 }}
>
{mapCoords.length > 1 && (
<>
<NaverMapPathOverlay coords={mapCoords} width={4} color="#FFD40D" outlineWidth={1} outlineColor="#eab308" />
<NaverMapMarkerOverlay
latitude={mapCoords[0].latitude}
longitude={mapCoords[0].longitude}
width={14} height={14} anchor={{ x: 0.5, y: 0.5 }}
>
<View style={styles.dotMarkerBlue} />
</NaverMapMarkerOverlay>
<NaverMapMarkerOverlay
latitude={mapCoords[mapCoords.length - 1].latitude}
longitude={mapCoords[mapCoords.length - 1].longitude}
width={14} height={14} anchor={{ x: 0.5, y: 0.5 }}
>
<View style={styles.dotMarkerRed} />
</NaverMapMarkerOverlay>
</>
)}
{hasTrack && (recordDetail?.photos ?? []).map((photo) => (
<NaverMapMarkerOverlay
key={photo.milestoneIndex}
latitude={photo.lat}
longitude={photo.lng}
width={24} height={24} anchor={{ x: 0.5, y: 0.5 }}
>
<View style={styles.photoMarkerWrap}>
<ExpoImage
source={{ uri: photo.imageUrl }}
style={styles.photoMarkerImg}
contentFit="cover"
/>
</View>
</NaverMapMarkerOverlay>
))}
</NaverMapView>
);
})()}
{/* 날짜/시간 배지 */}
<View style={styles.dateBadgeWrap}>
{(() => {
const { date, time } = formatMapDateParts(recordDetail?.startedAt, recordDetail?.endedAt);
return (
<View style={styles.dateBadge}>
<Text style={styles.dateBadgeDateText}>{date || "--"}</Text>
{!!time && <Text style={styles.dateBadgeTimeText}>{" " + time}</Text>}
</View>
);
})()}
</View>
</View>

{/* 통계 */}
Expand Down Expand Up @@ -550,14 +688,19 @@ export default function RecordScreen() {
/>

{/* 스탯 오버레이 */}
<ExpoImage
source={
OVERLAY_STATS[photoReportTemplate] ?? OVERLAY_STATS[0]
}
style={StyleSheet.absoluteFill}
contentFit="cover"
pointerEvents="none"
/>
{(() => {
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 <OverlayComponent {...overlayProps} />;
})()}
</View>
</ViewShot>

Expand All @@ -568,12 +711,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 ?? ""),
},
});
}}
>
<PencilSimpleIcon size={16} color="#FFFFFF" />
<Text style={styles.editChipText}>편집하기</Text>
Expand Down Expand Up @@ -715,6 +867,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",
Expand Down Expand Up @@ -819,4 +997,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: 24,
height: 24,
borderRadius: 12,
overflow: "hidden",
borderWidth: 2,
borderColor: "#FFFFFF",
},
photoMarkerImg: {
width: 24,
height: 24,
},
});
Loading
Loading