Skip to content

Commit 617a0c1

Browse files
authored
Merge pull request #82 from Central-MakeUs/feature/MODY-288
[MODY-288] 마이페이지 프로필 편집 기능 개발 완료
2 parents 5b15bb0 + 9459f15 commit 617a0c1

29 files changed

Lines changed: 573 additions & 65 deletions

File tree

Projects/App/Sources/DI/Assemblies/MyPage/AppAssembly+MyPage.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Swinject
99
import CoreAuthInterface
10+
import CoreCameraInterface
1011
import CoreModyImageInterface
1112
import CoreNetworkInterface
1213
import MyPageInterface
@@ -48,6 +49,7 @@ extension AppAssembly {
4849

4950
container.register(MyPageBuildable.self) { resolver in
5051
let imageLoader: RemoteImageLoading = resolver.resolve()
52+
let cameraCaptureBuilder: CameraCaptureBuildable = resolver.resolve()
5153

5254
return MyPageBuilder(
5355
makeMyPageFeature: { router, outputHandler in
@@ -65,7 +67,8 @@ extension AppAssembly {
6567
makeHealthDataSettingsFeature: { router in
6668
resolver.resolve(argument: router)
6769
},
68-
imageLoader: imageLoader
70+
imageLoader: imageLoader,
71+
cameraCaptureBuilder: cameraCaptureBuilder
6972
)
7073
}
7174
}

Projects/App/Sources/DI/Assemblies/MyPage/AppAssembly+MyPageProfile.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Swinject
99
import CoreAuthInterface
10+
import CoreModyImageInterface
1011
import MyPageInterface
1112
import MyPage
1213

@@ -19,10 +20,14 @@ extension AppAssembly {
1920
let (router, outputHandler) = arguments
2021
let authUseCase: AuthUseCaseProtocol = resolver.resolve()
2122
let myPageUseCase: MyPageUseCase = resolver.resolve()
23+
let imageUploadUseCase: ImageUploadUseCaseProtocol = resolver.resolve()
24+
let temporaryImageFileUseCase: TemporaryImageFileUseCaseProtocol = resolver.resolve()
2225

2326
return ProfileFeature(
2427
authUseCase: authUseCase,
2528
myPageUseCase: myPageUseCase,
29+
imageUploadUseCase: imageUploadUseCase,
30+
temporaryImageFileUseCase: temporaryImageFileUseCase,
2631
router: { [weak router] route in
2732
router?.route(from: route)
2833
},

Projects/Core/CoreCamera/Interface/Sources/Capture/CameraCaptureBuildable.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public protocol CameraCaptureBuildable {
1111
@MainActor
1212
func makeCameraViewController(
1313
source: CameraCaptureSource,
14+
isCropEnabled: Bool,
1415
onComplete: @escaping (CameraCaptureResult) -> Void,
1516
onCancel: @escaping () -> Void
1617
) -> UIViewController

Projects/Core/CoreCamera/Interface/Sources/Capture/CameraCaptureResult.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
import CoreModyImageInterface
99
import UIKit
1010

11-
/// 촬영 완료 후 호출자에게 전달되는 원본 파일과 화면용 크롭 정보입니다.
11+
/// 촬영 완료 후 호출자에게 전달되는 원본 파일과 화면용 미리보기 정보입니다.
1212
public struct CameraCaptureResult {
1313
public let originalFile: TemporaryImageFile
14-
/// 화면 표시용으로 다운샘플된 이미지에서 만든 크롭 미리보기입니다.
14+
/// crop이 활성화되면 crop 결과이며, 비활성화되면 전체 미리보기입니다.
1515
public let croppedPreviewImage: UIImage
1616
public let normalizedSelectionFrame: CGRect
1717

Projects/Core/CoreCamera/Sources/Builder/CameraCaptureBuilder.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public struct CameraCaptureBuilder: CameraCaptureBuildable {
1919
@MainActor
2020
public func makeCameraViewController(
2121
source: CameraCaptureSource,
22+
isCropEnabled: Bool,
2223
onComplete: @escaping (CameraCaptureResult) -> Void,
2324
onCancel: @escaping () -> Void
2425
) -> UIViewController {
@@ -29,6 +30,7 @@ public struct CameraCaptureBuilder: CameraCaptureBuildable {
2930

3031
return CameraContainerViewController(
3132
initialSource: source,
33+
isCropEnabled: isCropEnabled,
3234
capturedPhotoProcessor: capturedPhotoProcessor,
3335
onComplete: onComplete,
3436
onCancel: onCancel

Projects/Core/CoreCamera/Sources/Presentation/Camera/CameraContainerViewController.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ final class CameraContainerViewController: UIViewController {
1616
static let previewMaxPixelSize = 2048
1717

1818
private let initialSource: CameraCaptureSource
19+
let isCropEnabled: Bool
1920
let onComplete: (CameraCaptureResult) -> Void
2021
let onCancel: () -> Void
2122
let sessionController = CameraCaptureSessionController()
@@ -35,11 +36,13 @@ final class CameraContainerViewController: UIViewController {
3536

3637
init(
3738
initialSource: CameraCaptureSource,
39+
isCropEnabled: Bool = true,
3840
capturedPhotoProcessor: CameraCapturedPhotoProcessor,
3941
onComplete: @escaping (CameraCaptureResult) -> Void,
4042
onCancel: @escaping () -> Void
4143
) {
4244
self.initialSource = initialSource
45+
self.isCropEnabled = isCropEnabled
4346
self.capturedPhotoProcessor = capturedPhotoProcessor
4447
self.onComplete = onComplete
4548
self.onCancel = onCancel
@@ -102,14 +105,17 @@ private extension CameraContainerViewController {
102105
}
103106

104107
func setupLayout() {
105-
[
108+
var subviews: [UIView] = [
106109
previewView,
107110
selectedImageView,
108-
roiOverlayView,
109111
closeButton,
110112
bottomCameraShutterView,
111113
photoConfirmationContainerView
112-
].forEach {
114+
]
115+
if isCropEnabled {
116+
subviews.insert(roiOverlayView, at: 2)
117+
}
118+
subviews.forEach {
113119
view.addSubview($0)
114120
}
115121

@@ -121,8 +127,10 @@ private extension CameraContainerViewController {
121127
$0.edges.equalToSuperview()
122128
}
123129

124-
roiOverlayView.snp.makeConstraints {
125-
$0.edges.equalToSuperview()
130+
if isCropEnabled {
131+
roiOverlayView.snp.makeConstraints {
132+
$0.edges.equalToSuperview()
133+
}
126134
}
127135

128136
closeButton.snp.makeConstraints {

Projects/Core/CoreCamera/Sources/Presentation/Camera/Extensions/CameraContainerViewController+.swift

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ extension CameraContainerViewController {
5555
selectedImageView.isHidden = false
5656
bottomCameraShutterView.isHidden = true
5757
photoConfirmationContainerView.isHidden = false
58-
roiOverlayView.isHidden = false
58+
roiOverlayView.isHidden = !isCropEnabled
5959
closeButton.tintColor = .systemWhite
6060
sessionController.stop()
6161
}
@@ -77,17 +77,28 @@ extension CameraContainerViewController {
7777
guard let result = autoreleasepool(invoking: { () -> CameraCaptureResult? in
7878
guard let capturedPhoto else { return nil }
7979

80-
let cropOutput = CameraImageCropper().crop(
81-
image: capturedPhoto.previewImage,
82-
selectionFrame: roiOverlayView.selectionFrame,
83-
containerSize: roiOverlayView.bounds.size
84-
)
85-
guard let cropOutput else { return nil }
80+
let previewImage: UIImage
81+
let normalizedSelectionFrame: CGRect
82+
83+
if isCropEnabled {
84+
guard let cropOutput = CameraImageCropper().crop(
85+
image: capturedPhoto.previewImage,
86+
selectionFrame: roiOverlayView.selectionFrame,
87+
containerSize: roiOverlayView.bounds.size
88+
) else {
89+
return nil
90+
}
91+
previewImage = cropOutput.croppedImage
92+
normalizedSelectionFrame = cropOutput.normalizedSelectionFrame
93+
} else {
94+
previewImage = capturedPhoto.previewImage
95+
normalizedSelectionFrame = CGRect(x: 0, y: 0, width: 1, height: 1)
96+
}
8697

8798
let result = CameraCaptureResult(
8899
originalFile: capturedPhoto.originalFile,
89-
croppedPreviewImage: cropOutput.croppedImage,
90-
normalizedSelectionFrame: cropOutput.normalizedSelectionFrame
100+
croppedPreviewImage: previewImage,
101+
normalizedSelectionFrame: normalizedSelectionFrame
91102
)
92103
clearCapturedPhoto(removingFile: false)
93104
return result

Projects/Core/CoreCamera/Tests/Sources/CameraImageProcessingTests.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
// Created by 김동준 on 7/25/26.
66
//
77

8+
import CoreCameraInterface
9+
import CoreModyImageInterface
810
import UIKit
911
import XCTest
1012
@testable import CoreCamera
@@ -47,6 +49,43 @@ final class CameraImageProcessingTests: XCTestCase {
4749
CGRect(x: 0.25, y: 0.25, width: 0.5, height: 0.5)
4850
)
4951
}
52+
53+
@MainActor
54+
func testCropDisabledReturnsExistingPreviewAndFullFrame() throws {
55+
let previewImage = makeImage(size: CGSize(width: 300, height: 150))
56+
var capturedResult: CameraCaptureResult?
57+
let viewController = CameraContainerViewController(
58+
initialSource: .photoLibrary,
59+
isCropEnabled: false,
60+
capturedPhotoProcessor: CameraCapturedPhotoProcessor(
61+
temporaryImageFileUseCase: CameraTemporaryImageFileUseCaseStub(),
62+
previewMaxPixelSize: CameraContainerViewController.previewMaxPixelSize
63+
),
64+
onComplete: { capturedResult = $0 },
65+
onCancel: {}
66+
)
67+
68+
viewController.loadViewIfNeeded()
69+
viewController.setCapturedPhoto(
70+
CameraCapturedPhoto(
71+
originalFile: TemporaryImageFile(
72+
fileURL: FileManager.default.temporaryDirectory
73+
.appendingPathComponent(UUID().uuidString),
74+
fileName: "profile.jpg",
75+
contentType: "image/jpeg"
76+
),
77+
previewImage: previewImage
78+
)
79+
)
80+
viewController.completeCapture()
81+
82+
XCTAssertNil(viewController.roiOverlayView.superview)
83+
XCTAssertTrue(capturedResult?.croppedPreviewImage === previewImage)
84+
XCTAssertEqual(
85+
capturedResult?.normalizedSelectionFrame,
86+
CGRect(x: 0, y: 0, width: 1, height: 1)
87+
)
88+
}
5089
}
5190

5291
private extension CameraImageProcessingTests {
@@ -59,3 +98,23 @@ private extension CameraImageProcessingTests {
5998
}
6099
}
61100
}
101+
102+
private struct CameraTemporaryImageFileUseCaseStub: TemporaryImageFileUseCaseProtocol {
103+
func saveImage(
104+
data: Data,
105+
fileName: String
106+
) throws -> TemporaryImageFile {
107+
throw CocoaError(.featureUnsupported)
108+
}
109+
110+
func copyImage(
111+
at sourceURL: URL,
112+
fileName: String
113+
) throws -> TemporaryImageFile {
114+
throw CocoaError(.featureUnsupported)
115+
}
116+
117+
func removeImage(at fileURL: URL) throws {}
118+
119+
func removeExpiredImages(olderThan expirationInterval: TimeInterval) throws {}
120+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// ImageUploadFileProcessor.swift
3+
// CoreModyImage
4+
//
5+
// Created by 김동준 on 7/26/26.
6+
//
7+
8+
import CommonDomain
9+
import Foundation
10+
import Nuke
11+
12+
struct ImageUploadFileProcessor {
13+
func makeDownsampledJPEG(
14+
from sourceURL: URL,
15+
maximumPixelSize: Int
16+
) throws -> URL {
17+
guard maximumPixelSize > 0 else {
18+
throw NetworkError.invalidResponse
19+
}
20+
21+
let thumbnailOptions = ImageRequest.ThumbnailOptions(
22+
maxPixelSize: Float(maximumPixelSize)
23+
)
24+
let jpegEncoder = ImageEncoders.ImageIO(
25+
type: .jpeg,
26+
compressionRatio: 0.9
27+
)
28+
29+
guard let sourceData = try? Data(contentsOf: sourceURL, options: .mappedIfSafe),
30+
let thumbnail = thumbnailOptions.makeThumbnail(with: sourceData),
31+
let jpegData = jpegEncoder.encode(thumbnail) else {
32+
throw NetworkError.invalidResponse
33+
}
34+
35+
let destinationURL = FileManager.default.temporaryDirectory
36+
.appendingPathComponent(UUID().uuidString)
37+
.appendingPathExtension("jpg")
38+
39+
do {
40+
try jpegData.write(to: destinationURL, options: .atomic)
41+
} catch {
42+
try? FileManager.default.removeItem(at: destinationURL)
43+
throw NetworkError.invalidResponse
44+
}
45+
46+
return destinationURL
47+
}
48+
}

Projects/Core/CoreModyImage/Sources/Upload/Data/Repository/ImageUploadRepository.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,28 @@ public struct ImageUploadRepository: ImageUploadRepositoryProtocol {
4646

4747
public func putImage(
4848
fileURL: URL,
49-
to url: URL
49+
to url: URL,
50+
maximumPixelSize: Int?
5051
) async throws {
51-
let contentType = try contentType(for: fileURL)
52+
let uploadFileURL: URL
53+
if let maximumPixelSize {
54+
uploadFileURL = try ImageUploadFileProcessor().makeDownsampledJPEG(
55+
from: fileURL,
56+
maximumPixelSize: maximumPixelSize
57+
)
58+
} else {
59+
uploadFileURL = fileURL
60+
}
61+
defer {
62+
if uploadFileURL != fileURL {
63+
try? FileManager.default.removeItem(at: uploadFileURL)
64+
}
65+
}
66+
67+
let contentType = try contentType(for: uploadFileURL)
5268
let response = await uploadSession
5369
.upload(
54-
fileURL,
70+
uploadFileURL,
5571
to: url,
5672
method: .put,
5773
headers: [.contentType(contentType)]

0 commit comments

Comments
 (0)