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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ CameraPage
↓ props + route guard command
CameraCaptureWorkspace
├─ capture-photo / guide-feed
├─ captured photos → save-images-to-library
├─ host-controls
└─ finalize-session
↓
Expand Down Expand Up @@ -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 상태 머신을 추가하지 않는다.

포기하거나 제한된 것:
Expand Down Expand Up @@ -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는 이번 작업에서 실행하지 않았으며 별도 수동 검증이 필요하다.
147 changes: 134 additions & 13 deletions src/widgets/camera/capture-workspace/ui/captured-photos-layer.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -26,6 +29,10 @@ export function CapturedPhotosLayer({
onClose,
}: CapturedPhotosLayerProps) {
const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(
() => new Set(),
);
const { isSaving, saveImages } = useSaveImagesToLibrary();
const images = useMemo(
() =>
photos.map(({ id, uri }) => ({
Expand All @@ -34,22 +41,96 @@ 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;

const subscription = BackHandler.addEventListener(
"hardwareBackPress",
() => {
if (isSaving) return true;

if (galleryIndex !== null) {
setGalleryIndex(null);
return true;
Expand All @@ -61,7 +142,7 @@ export function CapturedPhotosLayer({
);

return () => subscription.remove();
}, [galleryIndex, handleClose, open]);
}, [galleryIndex, handleClose, isSaving, open]);

if (!open) return null;

Expand All @@ -83,34 +164,74 @@ export function CapturedPhotosLayer({
>
<VStack className="flex-1 bg-white pt-4">
<HStack className="items-center justify-between border-b border-outline-light px-6 py-3">
<Button
variant="ghost"
size="icon"
onPress={handleClose}
accessibilityLabel="촬영한 사진 목록 닫기"
>
<ButtonIcon as={IconChevronLeft} />
</Button>
<Box className="w-20">
<Button
variant="ghost"
size="icon"
disabled={isSaving}
onPress={handleClose}
accessibilityLabel="촬영한 사진 목록 닫기"
>
<ButtonIcon as={IconChevronLeft} />
</Button>
</Box>
<Text size="lg" className="font-semibold">
촬영한 사진
</Text>
<Box className="w-12" />
<Pressable
className="w-20 items-end"
disabled={images.length === 0 || isSaving}
onPress={handleToggleAll}
accessibilityRole="button"
accessibilityLabel={
isAllSelected ? "전체 사진 선택 해제" : "전체 사진 선택"
}
>
<Text bold className="text-primary">
{isAllSelected ? "전체 해제" : "전체 선택"}
</Text>
</Pressable>
</HStack>
{images.length > 0 ? (
<Text className="border-b border-outline-light px-6 py-3 text-label-muted">
선택됨 {selectedImages.length} / {images.length}
</Text>
) : null}
<VStack className="flex-1">
<PhotoGrid
images={images}
selectedImages={selectedImages}
columns={3}
emptyMessage="아직 촬영한 사진이 없습니다."
onPress={(_, index) => setGalleryIndex(index)}
onSelectionPress={handleTogglePhoto}
/>
</VStack>
{images.length > 0 ? (
<VStack className="border-t border-outline-light px-6 py-4">
<Button
variant="gradient"
size="lg"
disabled={selectedImages.length === 0 || isSaving}
isLoading={isSaving}
onPress={() => void handleSaveSelectedPhotos()}
accessibilityLabel={`${selectedImages.length}장의 선택한 촬영 사진 저장`}
>
<ButtonText>
{`선택한 사진 저장 (${selectedImages.length})`}
</ButtonText>
</Button>
</VStack>
) : null}
</VStack>
</SafeAreaView>

<PhotoGalleryModal
open={galleryIndex !== null}
images={images}
initialIndex={galleryIndex ?? 0}
selectedImageIds={selectedPhotoIds}
onToggleSelection={handleTogglePhoto}
onClose={() => setGalleryIndex(null)}
/>
</View>
Expand Down
Loading