From 6f2141d5706a8d3a9e0da4d36da9726bb982d688 Mon Sep 17 00:00:00 2001 From: lioba00700 Date: Sat, 5 Sep 2026 13:03:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=EC=B4=AC?= =?UTF-8?q?=EC=98=81=20=EC=82=AC=EC=A7=84=20=EB=B3=B4=EA=B4=80=ED=95=A8=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/captured-photos-layer.tsx | 147 ++++++++++++++++-- 1 file changed, 134 insertions(+), 13 deletions(-) diff --git a/src/widgets/camera/capture-workspace/ui/captured-photos-layer.tsx b/src/widgets/camera/capture-workspace/ui/captured-photos-layer.tsx index d15bf3fd..546e879d 100644 --- a/src/widgets/camera/capture-workspace/ui/captured-photos-layer.tsx +++ b/src/widgets/camera/capture-workspace/ui/captured-photos-layer.tsx @@ -1,17 +1,20 @@ import type { SessionPhoto } from "@features/camera/capture-photo"; +import { useSaveImagesToLibrary } from "@features/photo/save-images-to-library"; import { Box, Button, ButtonIcon, + ButtonText, HStack, PhotoGalleryModal, PhotoGrid, + Pressable, Text, VStack, } from "@shared/ui"; import { IconChevronLeft } from "@tabler/icons-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { BackHandler, View } from "react-native"; +import { Alert, BackHandler, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; interface CapturedPhotosLayerProps { @@ -26,6 +29,10 @@ export function CapturedPhotosLayer({ onClose, }: CapturedPhotosLayerProps) { const [galleryIndex, setGalleryIndex] = useState(null); + const [selectedPhotoIds, setSelectedPhotoIds] = useState>( + () => new Set(), + ); + const { isSaving, saveImages } = useSaveImagesToLibrary(); const images = useMemo( () => photos.map(({ id, uri }) => ({ @@ -34,15 +41,87 @@ export function CapturedPhotosLayer({ })), [photos], ); + const selectedImages = useMemo( + () => images.filter(({ id }) => selectedPhotoIds.has(id)), + [images, selectedPhotoIds], + ); + const isAllSelected = + images.length > 0 && selectedImages.length === images.length; useEffect(() => { - if (!open) setGalleryIndex(null); + if (!open) { + setGalleryIndex(null); + setSelectedPhotoIds(new Set()); + } }, [open]); const handleClose = useCallback(() => { + if (isSaving) return; + setGalleryIndex(null); + setSelectedPhotoIds(new Set()); onClose(); - }, [onClose]); + }, [isSaving, onClose]); + + const handleTogglePhoto = useCallback( + ({ id }: { id: string }) => { + if (isSaving) return; + + setSelectedPhotoIds((previousIds) => { + const nextIds = new Set(previousIds); + if (nextIds.has(id)) { + nextIds.delete(id); + } else { + nextIds.add(id); + } + return nextIds; + }); + }, + [isSaving], + ); + + const handleToggleAll = useCallback(() => { + if (isSaving) return; + + setSelectedPhotoIds( + isAllSelected ? new Set() : new Set(images.map(({ id }) => id)), + ); + }, [images, isAllSelected, isSaving]); + + const handleSaveSelectedPhotos = useCallback(async () => { + if (selectedImages.length === 0 || isSaving) return; + + const result = await saveImages(selectedImages); + if (result.status === "BUSY") return; + if (result.status === "PERMISSION_DENIED") { + Alert.alert( + "사진 권한 필요", + "선택한 사진을 저장하려면 사진 보관함 추가 권한이 필요합니다.", + ); + return; + } + + setSelectedPhotoIds(new Set(result.failedImageIds)); + if (result.status === "SAVED") { + Alert.alert( + "사진 저장 완료", + `${result.savedCount}장의 사진을 사진 보관함에 저장했습니다.`, + ); + return; + } + if (result.status === "PARTIALLY_SAVED") { + Alert.alert( + "일부 사진 저장 실패", + `${result.savedCount}장은 저장했고, ${result.failedImageIds.length}장은 저장하지 못했습니다. 실패한 사진을 다시 시도해주세요.`, + ); + return; + } + + Alert.alert( + "사진 저장 실패", + "선택한 사진을 저장하지 못했습니다. 잠시 후 다시 시도해주세요.", + ); + }, [isSaving, saveImages, selectedImages]); useEffect(() => { if (!open) return; @@ -50,6 +129,8 @@ export function CapturedPhotosLayer({ const subscription = BackHandler.addEventListener( "hardwareBackPress", () => { + if (isSaving) return true; + if (galleryIndex !== null) { setGalleryIndex(null); return true; @@ -61,7 +142,7 @@ export function CapturedPhotosLayer({ ); return () => subscription.remove(); - }, [galleryIndex, handleClose, open]); + }, [galleryIndex, handleClose, isSaving, open]); if (!open) return null; @@ -83,27 +164,65 @@ export function CapturedPhotosLayer({ > - + + + 촬영한 사진 - + + + {isAllSelected ? "전체 해제" : "전체 선택"} + + + {images.length > 0 ? ( + + 선택됨 {selectedImages.length} / {images.length} + + ) : null} setGalleryIndex(index)} + onSelectionPress={handleTogglePhoto} /> + {images.length > 0 ? ( + + + + ) : null} @@ -111,6 +230,8 @@ export function CapturedPhotosLayer({ open={galleryIndex !== null} images={images} initialIndex={galleryIndex ?? 0} + selectedImageIds={selectedPhotoIds} + onToggleSelection={handleTogglePhoto} onClose={() => setGalleryIndex(null)} /> From a74513702c9f022ede4607ad83d3914ca660be8a Mon Sep 17 00:00:00 2001 From: lioba00700 Date: Sat, 5 Sep 2026 13:03:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EC=9D=BC=EB=B0=98=20=EC=B4=AC?= =?UTF-8?q?=EC=98=81=20=EC=82=AC=EC=A7=84=20=EC=A0=80=EC=9E=A5=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dr-0045-camera-page-workspace-responsibility-boundary.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/architecture/adr-0045-camera-page-workspace-responsibility-boundary.md b/docs/architecture/adr-0045-camera-page-workspace-responsibility-boundary.md index 49315a23..aaea4d90 100644 --- a/docs/architecture/adr-0045-camera-page-workspace-responsibility-boundary.md +++ b/docs/architecture/adr-0045-camera-page-workspace-responsibility-boundary.md @@ -29,6 +29,7 @@ CameraPage ↓ props + route guard command CameraCaptureWorkspace ├─ capture-photo / guide-feed + ├─ captured photos → save-images-to-library ├─ host-controls └─ finalize-session ↓ @@ -117,6 +118,8 @@ Camera로 돌아올 때 유지되며, VisionCamera와 Host connection은 Feature - Viewer RPC 우선 결과, SSE 1초 fallback과 pre-LiveKit 종료 정책을 한 controller에서 유지한다. - 다중 사진 저장이 권한, single-flight와 부분 성공을 명시적으로 표현한다. +- RTC 방 생성 여부와 관계없이 촬영 사진 layer가 같은 다중 사진 저장 Feature를 + 사용한다. - Widget API나 전역 Camera/RTC 상태 머신을 추가하지 않는다. 포기하거나 제한된 것: @@ -144,6 +147,9 @@ Camera로 돌아올 때 유지되며, VisionCamera와 Host connection은 Feature `useRtcHostTerminationController`가 계속 직렬화한다. - Viewer SSE/RPC 결과 경합과 session cleanup은 `useRtcViewerSessionController`가 담당한다. +- 일반 촬영 사진은 Capture Workspace의 사진 layer에서 선택할 수 있고, + `save-images-to-library` Feature가 로컬 파일 저장과 권한·부분 실패 정책을 + 처리한다. - TypeScript, Host/Viewer RTC 회귀 테스트와 Steiger 검사는 통과했다. - 실제 기기에서 Camera capture, Host/Viewer 연결, 종료 결과와 Media Library 권한 dialog는 이번 작업에서 실행하지 않았으며 별도 수동 검증이 필요하다.