From 4178757fc2fd41f85cb69990778359f6d0ba50b2 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 18:18:50 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor(data):=20whisper.=20mlx=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=9A=A9=EB=9F=89=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?fetch=20=EA=B5=AC=ED=98=84=20-=20whiper=EB=8A=94=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20=ED=86=B5=ED=95=B4=20?= =?UTF-8?q?ModelVariant=EB=A5=BC=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EC=9C=BC=EB=AF=80=EB=A1=9C=20repository=EC=97=90?= =?UTF-8?q?=EC=84=9C=20String=EC=9C=BC=EB=A1=9C=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=B7=B0=EB=AA=A8=EB=8D=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=ED=95=A9=EB=8B=88=EB=8B=A4.=20-=20mlx?= =?UTF-8?q?=EB=8A=94=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20(3.59GB)=20-?= =?UTF-8?q?=20downloadModelCard=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EC=9A=A9=EB=9F=89=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnDevice/Whisper/WhisperKitProvider.swift | 5 ++++ .../Whisper/WhisperDataSource.swift | 3 +++ .../OnDevice/OnDeviceRepository.swift | 2 ++ .../OnDevice/OnDeviceStatusUseCase.swift | 7 ++++++ .../Component/Common/DownloadModelCard.swift | 24 +++++++++++++++---- .../OnBoarding/OnBoardingDownloadView.swift | 3 ++- .../OnBoarding/OnBoardingViewController.swift | 5 ++-- .../DownloadOnDeviceViewController.swift | 6 +++-- .../OnBoarding/OnBoardingViewModel.swift | 19 +++++++++++---- .../Recording/DownloadOnDeviceViewModel.swift | 12 +++++++++- 10 files changed, 71 insertions(+), 15 deletions(-) diff --git a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift index e764652b..5aef1b28 100644 --- a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift @@ -27,6 +27,11 @@ public actor WhisperKitProvider: WhisperDataSource { self.languageRepository = languageRepository } + public func fetchModelVariant() async -> ModelVariant { + let recommended = WhisperKit.recommendedModels().default + return ModelVariant.allCases.first { recommended.lowercased().contains($0.description.lowercased()) } ?? .tiny + } + public func download(progressHandler: @Sendable @escaping (Progress) -> Void) async throws { let recommendedModel = WhisperKit.recommendedModels().default self.recommendedModel = recommendedModel diff --git a/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift b/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift index ece8b0eb..aa0e950e 100644 --- a/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift +++ b/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift @@ -3,6 +3,9 @@ import WhisperKit /// Whisper STT 모델 엔진을 제어하고 음성 전사 데이터를 제공하는 데이터 소스 인터페이스. public protocol WhisperDataSource: Sendable { + /// 추천 모델의 Variant 정보를 가져옵니다. + func fetchModelVariant() async -> ModelVariant + /// 모델의 다운로드 경로를 전달합니다. func getDownloadPath() async throws(WhisperDataSourceError) -> URL diff --git a/Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift b/Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift index 4aeeb306..d83dbaf6 100644 --- a/Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift +++ b/Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift @@ -1,6 +1,8 @@ import Foundation public protocol OnDeviceRepository: Sendable { + /// 모델의 용량을 노출합니다. + var modelSize: String { get async } /// 모델 파일을 다운로드하여 로컬 캐시에 저장합니다. (메모리 적재 X) func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) /// 모델을 제거합니다. diff --git a/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift b/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift index 51942f71..da853b1f 100644 --- a/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift +++ b/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift @@ -10,6 +10,8 @@ public protocol OnDeviceStatusUseCase: Sendable { func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) /// 현재 상태 조회 func checkStatus(model: ChaGokModel) async -> OnDeviceStatus + /// 모델의 용량 정보를 조회합니다 + func fetchModelSize(model: ChaGokModel) async -> String } public actor DefaultOnDeviceStatusUseCase: OnDeviceStatusUseCase { @@ -147,6 +149,11 @@ public actor DefaultOnDeviceStatusUseCase: OnDeviceStatusUseCase { return OnDeviceStatus(storage: .notDownloaded) } + public func fetchModelSize(model: ChaGokModel) async -> String { + guard let repo = repo(for: model) else { return "" } + return await repo.modelSize + } + private func syncStatus(model: ChaGokModel) async { _ = await checkStatus(model: model) } diff --git a/Presentation/Sources/Component/Common/DownloadModelCard.swift b/Presentation/Sources/Component/Common/DownloadModelCard.swift index 4cfcf48f..c689d9cb 100644 --- a/Presentation/Sources/Component/Common/DownloadModelCard.swift +++ b/Presentation/Sources/Component/Common/DownloadModelCard.swift @@ -8,6 +8,14 @@ final class DownloadModelCard: UIStackView { let style: ProgressStyle var storage: OnDeviceStatus.StorageState var errorMessage: String? + var modelSize: String + + private let sizeLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = UIColor.point800 + return label + }() // MARK: - Initialize @@ -17,6 +25,7 @@ final class DownloadModelCard: UIStackView { style: ProgressStyle, storage: OnDeviceStatus.StorageState, errorMessage: String? = nil, + modelSize: String = "", frame: CGRect = .zero ) { self.symbolName = symbolName @@ -24,6 +33,7 @@ final class DownloadModelCard: UIStackView { self.style = style self.storage = storage self.errorMessage = errorMessage + self.modelSize = modelSize super.init(frame: frame) setup() } @@ -91,8 +101,8 @@ extension DownloadModelCard { let container = UIStackView() let imageView = UIImageView() let nameLabel = UILabel() - - for item in [container, imageView, nameLabel] { + + for item in [container, imageView, nameLabel, sizeLabel] { item.translatesAutoresizingMaskIntoConstraints = false } @@ -109,7 +119,9 @@ extension DownloadModelCard { container.spacing = 8 // spacer let spacer = UIView() - for item in [imageView, nameLabel, spacer] { + // model size + sizeLabel.setTypography(text: modelSize, style: .label) + for item in [imageView, nameLabel, spacer, sizeLabel] { container.addArrangedSubview(item) } @@ -120,9 +132,13 @@ extension DownloadModelCard { // MARK: - Update extension DownloadModelCard { - func updateStatus(_ storage: OnDeviceStatus.StorageState, errorMessage: String?) { + func updateStatus(_ storage: OnDeviceStatus.StorageState, errorMessage: String?, modelSize: String? = nil) { self.storage = storage self.errorMessage = errorMessage + if let modelSize { + self.modelSize = modelSize + sizeLabel.setTypography(text: modelSize, style: .label) + } setNeedsUpdateProperties() } diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift index 5267ce7d..344a473a 100644 --- a/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift @@ -34,7 +34,8 @@ final class OnBoardingDownloadView: UIStackView { symbolName: "externaldrive", modelName: "Gemma-4", style: .immutable, - storage: vm.status.storage + storage: vm.status.storage, + modelSize: vm.modelSize ) /// 남는 수직 공간을 흡수하는 빈 뷰 (OnBoardingCardView의 imageContainer 역할) diff --git a/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift index 74acff1a..e4345a2e 100644 --- a/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift +++ b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift @@ -68,12 +68,11 @@ public final class OnBoardingViewController: ViewController { // 버튼 상태 업데이트 primaryButton.configuration?.title = vm.primaryButtonTitle - primaryButton.isUserInteractionEnabled = vm.isPrimaryButtonEnabled + primaryButton.isHidden = !vm.isPrimaryButtonEnabled secondButton.configuration?.title = vm.secondButtonTitle secondButton.isUserInteractionEnabled = vm.isSecondButtonEnabled primaryButton.configuration?.baseBackgroundColor = vm - .isPrimaryButtonBgColor ? (vm.isPrimaryButtonEnabled ? UIColor.point600 : UIColor.gray600) : UIColor - .point200 + .isPrimaryButtonBgColor ? UIColor.point600 : UIColor.point200 .withAlphaComponent(Constant.backgroundOpacity) primaryButton.configuration?.baseForegroundColor = UIColor.gray900 // paginView diff --git a/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift b/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift index fd64b234..09477799 100644 --- a/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift +++ b/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift @@ -64,7 +64,8 @@ public final class DownloadOnDeviceViewController: UIViewController, Alertable { symbolName: "externaldrive", modelName: "Whisper", style: .default, - storage: vm.status.storage + storage: vm.status.storage, + modelSize: vm.modelSize ) // MARK: - Initialize @@ -184,7 +185,8 @@ private extension DownloadOnDeviceViewController { if isShowingCard { downloadModelCard.updateStatus( vm.status.storage, - errorMessage: vm.errorMessage + errorMessage: vm.errorMessage, + modelSize: vm.modelSize ) } } diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift index 1cf84d42..4c1848c8 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift @@ -57,7 +57,7 @@ public final class OnBoardingViewModel { storage: .notDownloaded ) private(set) var scrollEnabled: Bool = true - + private(set) var modelSize: String = "" private var isPaging: Bool = false private(set) var steps: [Step] = Step.allCases @@ -68,7 +68,7 @@ public final class OnBoardingViewModel { case .download: switch status.storage { case .downloading: - return "다운로드 중입니다..." + return "" case .downloaded: return "다음" case .failed: @@ -148,7 +148,7 @@ extension OnBoardingViewModel { isPaging = true finishOnBoarding() default: // 다음 - guard currentStep != .download else { + guard !currentStep.isDownload else { switch status.storage { case .downloading: return @@ -201,7 +201,12 @@ extension OnBoardingViewModel { func checkModelSupport() async { let support = await availableSupportModelRepository.checkMLXSupportModel() modelSupport = support.model == .gemma4_e2b_4bit - steps = modelSupport ? Step.allCases : Step.allCases.filter { $0 != .download } + if modelSupport { + self.modelSize = await mlxRepository.modelSize + steps = [.first, .second, .micPermission, .download, .finish] + } else { + steps = [.first, .second, .micPermission, .finish] + } if !steps.contains(currentStep) { currentStep = .finish } @@ -321,6 +326,12 @@ extension OnBoardingViewModel { } struct PreviewOnDeviceRepository: OnDeviceRepository { + var modelSize: String { + get async { + "0 MB" + } + } + func checkStatus() async -> Domain.OnDeviceStatus { .init(storage: .downloaded) } diff --git a/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift index f977c408..d910556a 100644 --- a/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift +++ b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift @@ -17,7 +17,8 @@ public final class DownloadOnDeviceViewModel { /// 온디바이스 모델의 통합 상태값 private(set) var status: OnDeviceStatus = .init(storage: .notDownloaded) private(set) var errorMessage: String? - + private(set) var modelSize: String = "" + public weak var coordinator: DownloadOnDeviceCoordinatorDelegate? private let onDeviceStatusUseCase: any OnDeviceStatusUseCase @@ -38,6 +39,7 @@ public final class DownloadOnDeviceViewModel { onDeviceStatusUseCase: any OnDeviceStatusUseCase ) { self.onDeviceStatusUseCase = onDeviceStatusUseCase + onAppearSize() observeDownloadStatus() } } @@ -45,6 +47,14 @@ public final class DownloadOnDeviceViewModel { // MARK: - Actions extension DownloadOnDeviceViewModel { + + /// 모델 다운로드 용량 크기를 가져옵니다. + func onAppearSize() { + Task { + modelSize = await onDeviceStatusUseCase.fetchModelSize(model: .whisper) + } + } + /// 온디바이스 모델(Whisper)의 상태 스트림을 구독하여 상태를 관찰합니다. private func observeDownloadStatus() { statusObservationTask?.cancel() From 70be487dff3ae1451cb8378fbc30a6868d8c599c Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 18:20:15 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor(presentation):=20iPad=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20iPad=EC=9D=98=20=EA=B2=BD=EC=9A=B0=20sheet=EA=B0=80=20mediu?= =?UTF-8?q?m=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=9D=B4=20=EA=B9=A8=EC=A7=80=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=EB=B0=9C=EC=83=9D=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20Device?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=99=80=20iPad=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20.large()=EB=A1=9C=20sheet=EB=A5=BC=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/Coordinator/MainCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/App/Sources/Coordinator/MainCoordinator.swift b/App/Sources/Coordinator/MainCoordinator.swift index 288f332f..f1ff69eb 100644 --- a/App/Sources/Coordinator/MainCoordinator.swift +++ b/App/Sources/Coordinator/MainCoordinator.swift @@ -112,7 +112,8 @@ extension MainCoordinator: MainCoordinatorDelegate { navController.setViewControllers([downloadVC], animated: false) if let sheet = navController.sheetPresentationController { - sheet.detents = [.medium()] + let isPad = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.model.lowercased().contains("ipad") + sheet.detents = isPad ? [.large()] : [.medium()] sheet.prefersGrabberVisible = true } } From 21501b3ffb19ff0fe4b2c9f209bde4858746361a Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 18:24:56 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor(presentation):=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20=EA=B5=AC=ED=98=84=EC=B2=B4=EC=97=90=20mlx=EB=8A=94=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EB=B0=8F=20whiper?= =?UTF-8?q?=EB=8A=94=20=EC=B6=94=EC=B2=9C=20=EB=AA=A8=EB=8D=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultMlxOnDeviceRepository.swift | 6 ++ .../DefaultWhisperOnDeviceRepository.swift | 18 +++++ .../ViewModel/OnBoarding/OnBoardingStep.swift | 66 ++++--------------- 3 files changed, 36 insertions(+), 54 deletions(-) diff --git a/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift b/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift index 07401b32..7dea6537 100644 --- a/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift +++ b/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift @@ -11,6 +11,12 @@ public final class DefaultMlxOnDeviceRepository: OnDeviceRepository { self.provider = provider } + public var modelSize: String { + get async { + return "약 3.58 GB" + } + } + public func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { do { try await provider.download { progress in diff --git a/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift index 7c2ed777..273f2eb1 100644 --- a/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift +++ b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift @@ -12,6 +12,24 @@ public struct DefaultWhisperOnDeviceRepository: OnDeviceRepository { self.provider = provider } + public var modelSize: String { + get async { + let variant = await provider.fetchModelVariant() + switch variant { + case .tiny, .tinyEn: + return "약 75 MB" + case .base, .baseEn: + return "약 142 MB" + case .small, .smallEn: + return "약 466 MB" + case .medium, .mediumEn: + return "약 1.5 GB" + case .large, .largev2, .largev3: + return "약 3.0 GB" + } + } + } + public func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { do { try await provider.download { progress in diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift index 62313336..46c560f2 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift @@ -13,31 +13,13 @@ struct OnBoardingItem: Equatable { } } -enum Step: Int, CaseIterable, Equatable { - case first = 0 +enum Step: Equatable { + case first case second case micPermission case download case finish - static func matchingStep(_ val: Int) -> Step { - switch val { - case 0: - return .first - case 1: - return .second - case 2: - return .micPermission - case 3: - return .download - case 4: - return .finish - default: - AppLogger.warning("매칭되지 않는 Int값이 들어왔습니다, value: \(val)") - return .first - } - } - var item: OnBoardingItem { switch self { case .first: @@ -61,7 +43,7 @@ enum Step: Int, CaseIterable, Equatable { case .download: OnBoardingItem( headline: "기기에서 바로 작동하도록,\n몇 가지를 준비할게요.", - body: "녹음과 요약을 기기 안에서 처리하기 위해\n필요한 모델을 다운로드 해요.\nWi-Fi연결을 권장하며 몇 분 정도 걸려요." + body: "녹음과 요약을 기기 안에서 처리하기 위해\n필요한 모델을 다운로드해요.\nWi-Fi 환경을 권장하며\n나중에 설정에서도 다운로드할 수 있어요.", ) case .finish: OnBoardingItem( @@ -70,41 +52,17 @@ enum Step: Int, CaseIterable, Equatable { ) } } +} - func next() -> Self { - switch self { - case .first: - return .second - case .second: - return .micPermission - case .micPermission: - return .download - case .download: - return .finish - case .finish: - return .finish - } - } - - func prev() -> Self { - switch self { - case .first: - return .first - case .second: - return .first - case .micPermission: - return .second - case .download: - return .micPermission - case .finish: - return .download - } +extension Step: CaseIterable { + static var allCases: [Step] { + [.first, .second, .micPermission, .download, .finish] } +} - func skip() -> Self { - if self == .first { - return .finish - } - return self +extension Step { + var isDownload: Bool { + if case .download = self { return true } + return false } } From b865b0fc1e67bedb67ae75aa6f82e265e20a9333 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 18:32:00 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20swiftformat=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Sources/Coordinator/MainCoordinator.swift | 3 ++- .../OnDevice/Whisper/WhisperKitProvider.swift | 2 +- Data/Sources/Interfaces/Whisper/WhisperDataSource.swift | 2 +- .../OnDevice/DefaultWhisperOnDeviceRepository.swift | 2 +- .../Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift | 6 ++++++ .../Mocks/OnDevice/MockOnDeviceStatusUseCase.swift | 4 ++++ .../Sources/Component/Common/DownloadModelCard.swift | 2 +- .../Sources/ViewModel/OnBoarding/OnBoardingStep.swift | 2 +- .../Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift | 2 +- .../ViewModel/Recording/DownloadOnDeviceViewModel.swift | 5 ++--- 10 files changed, 20 insertions(+), 10 deletions(-) diff --git a/App/Sources/Coordinator/MainCoordinator.swift b/App/Sources/Coordinator/MainCoordinator.swift index f1ff69eb..78edfe52 100644 --- a/App/Sources/Coordinator/MainCoordinator.swift +++ b/App/Sources/Coordinator/MainCoordinator.swift @@ -112,7 +112,8 @@ extension MainCoordinator: MainCoordinatorDelegate { navController.setViewControllers([downloadVC], animated: false) if let sheet = navController.sheetPresentationController { - let isPad = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.model.lowercased().contains("ipad") + let isPad = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.model.lowercased() + .contains("ipad") sheet.detents = isPad ? [.large()] : [.medium()] sheet.prefersGrabberVisible = true } diff --git a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift index 5aef1b28..6d1f2a30 100644 --- a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift @@ -31,7 +31,7 @@ public actor WhisperKitProvider: WhisperDataSource { let recommended = WhisperKit.recommendedModels().default return ModelVariant.allCases.first { recommended.lowercased().contains($0.description.lowercased()) } ?? .tiny } - + public func download(progressHandler: @Sendable @escaping (Progress) -> Void) async throws { let recommendedModel = WhisperKit.recommendedModels().default self.recommendedModel = recommendedModel diff --git a/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift b/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift index aa0e950e..87577259 100644 --- a/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift +++ b/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift @@ -5,7 +5,7 @@ import WhisperKit public protocol WhisperDataSource: Sendable { /// 추천 모델의 Variant 정보를 가져옵니다. func fetchModelVariant() async -> ModelVariant - + /// 모델의 다운로드 경로를 전달합니다. func getDownloadPath() async throws(WhisperDataSourceError) -> URL diff --git a/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift index 273f2eb1..3dd73b47 100644 --- a/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift +++ b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift @@ -29,7 +29,7 @@ public struct DefaultWhisperOnDeviceRepository: OnDeviceRepository { } } } - + public func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { do { try await provider.download { progress in diff --git a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift index c68b6169..a6455009 100644 --- a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift +++ b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift @@ -6,6 +6,12 @@ import XCTest public final class MockOnDeviceRepository: OnDeviceRepository { public init() {} + public var modelSize: String { + get async { + "0 MB" + } + } + public var downloadResult: Result = .success(()) public var deleteResult: Result = .success(OnDeviceStatus( storage: .notDownloaded diff --git a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift index 37e820c1..d241e0ec 100644 --- a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift +++ b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift @@ -31,6 +31,10 @@ public final class MockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked return checkStatusResult } + public func fetchModelSize(model: ChaGokModel) async -> String { + return "약 75 MB" + } + public func subscribe(model: ChaGokModel) async -> AsyncStream { actualSubscribeCallCount += 1 subscribedModel = model diff --git a/Presentation/Sources/Component/Common/DownloadModelCard.swift b/Presentation/Sources/Component/Common/DownloadModelCard.swift index c689d9cb..ef67b248 100644 --- a/Presentation/Sources/Component/Common/DownloadModelCard.swift +++ b/Presentation/Sources/Component/Common/DownloadModelCard.swift @@ -101,7 +101,7 @@ extension DownloadModelCard { let container = UIStackView() let imageView = UIImageView() let nameLabel = UILabel() - + for item in [container, imageView, nameLabel, sizeLabel] { item.translatesAutoresizingMaskIntoConstraints = false } diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift index 46c560f2..327259ef 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift @@ -43,7 +43,7 @@ enum Step: Equatable { case .download: OnBoardingItem( headline: "기기에서 바로 작동하도록,\n몇 가지를 준비할게요.", - body: "녹음과 요약을 기기 안에서 처리하기 위해\n필요한 모델을 다운로드해요.\nWi-Fi 환경을 권장하며\n나중에 설정에서도 다운로드할 수 있어요.", + body: "녹음과 요약을 기기 안에서 처리하기 위해\n필요한 모델을 다운로드해요.\nWi-Fi 환경을 권장하며\n나중에 설정에서도 다운로드할 수 있어요." ) case .finish: OnBoardingItem( diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift index 4c1848c8..1bf5657f 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift @@ -202,7 +202,7 @@ extension OnBoardingViewModel { let support = await availableSupportModelRepository.checkMLXSupportModel() modelSupport = support.model == .gemma4_e2b_4bit if modelSupport { - self.modelSize = await mlxRepository.modelSize + modelSize = await mlxRepository.modelSize steps = [.first, .second, .micPermission, .download, .finish] } else { steps = [.first, .second, .micPermission, .finish] diff --git a/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift index d910556a..ce299529 100644 --- a/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift +++ b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift @@ -18,7 +18,7 @@ public final class DownloadOnDeviceViewModel { private(set) var status: OnDeviceStatus = .init(storage: .notDownloaded) private(set) var errorMessage: String? private(set) var modelSize: String = "" - + public weak var coordinator: DownloadOnDeviceCoordinatorDelegate? private let onDeviceStatusUseCase: any OnDeviceStatusUseCase @@ -47,14 +47,13 @@ public final class DownloadOnDeviceViewModel { // MARK: - Actions extension DownloadOnDeviceViewModel { - /// 모델 다운로드 용량 크기를 가져옵니다. func onAppearSize() { Task { modelSize = await onDeviceStatusUseCase.fetchModelSize(model: .whisper) } } - + /// 온디바이스 모델(Whisper)의 상태 스트림을 구독하여 상태를 관찰합니다. private func observeDownloadStatus() { statusObservationTask?.cancel() From 927cad7a0d2a3e2b19e007c2c33a98141d4babf9 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 18:53:03 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor(presentationTest):=20=EB=B7=B0?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=88=98=EC=A0=95=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20unitTest=20=EC=A0=81=EC=9A=A9=20-=20modelSize?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=98=EC=98=80=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Setting/SettingViewModel+Preview.swift | 4 ++++ .../OnBoarding/OnBoardingViewModelTests.swift | 23 ++++++++++++------- .../DownloadOnDeviceViewModelTests.swift | 4 ++++ .../Tests/Setting/SettingViewModelTests.swift | 11 +++++++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift b/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift index fdab2166..0bf0b23d 100644 --- a/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift +++ b/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift @@ -68,6 +68,10 @@ import Foundation func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) {} func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) {} + + func fetchModelSize(model: ChaGokModel) async -> String { + return "약 75 MB" + } } } #endif diff --git a/Presentation/Tests/OnBoarding/OnBoardingViewModelTests.swift b/Presentation/Tests/OnBoarding/OnBoardingViewModelTests.swift index 67c751f8..066ee448 100644 --- a/Presentation/Tests/OnBoarding/OnBoardingViewModelTests.swift +++ b/Presentation/Tests/OnBoarding/OnBoardingViewModelTests.swift @@ -87,7 +87,8 @@ final class OnBoardingViewModelTests: XCTestCase { func test_마지막스텝인경우_버튼타이틀과_상태가_변경된다() { let sut = makeSUT() - sut.viewModel.syncPageState(nextStep: Step.finish.rawValue) // 4 + let finishIndex = sut.viewModel.steps.firstIndex(of: .finish) ?? 4 + sut.viewModel.syncPageState(nextStep: finishIndex) XCTAssertEqual(sut.viewModel.currentStep, .finish) XCTAssertEqual(sut.viewModel.primaryButtonTitle, "시작하기") @@ -107,7 +108,8 @@ final class OnBoardingViewModelTests: XCTestCase { await sut.mockSTTRepo.setCheckResult(.notDetermined) await sut.mockSTTRepo.setRequestResult(.success(.authorized)) - sut.viewModel.syncPageState(nextStep: Step.micPermission.rawValue) + let micPermissionIndex = sut.viewModel.steps.firstIndex(of: .micPermission) ?? 2 + sut.viewModel.syncPageState(nextStep: micPermissionIndex) // Task 내부 비동기 호출 대기 (안전하게 0.3초 대기) try? await Task.sleep(nanoseconds: 300_000_000) @@ -131,13 +133,14 @@ final class OnBoardingViewModelTests: XCTestCase { scrolledIndex = nextIndex } - XCTAssertEqual(scrolledIndex, Step.second.rawValue) // 1 + XCTAssertEqual(scrolledIndex, 1) } func test_primaryButtonAction_마지막스텝에서_온보딩을_완료하고_화면을_전환한다() async { let sut = makeSUT() - sut.viewModel.syncPageState(nextStep: Step.finish.rawValue) + let finishIndex = sut.viewModel.steps.firstIndex(of: .finish) ?? 4 + sut.viewModel.syncPageState(nextStep: finishIndex) sut.mockCheckFirstLaunchRepo.setReturnValue(true) sut.mockFolderRepo.setCreateResult(.success(Folder(name: Policy.defaultFolderName, kind: .default))) @@ -173,7 +176,7 @@ final class OnBoardingViewModelTests: XCTestCase { scrolledIndex = nextIndex } - XCTAssertEqual(scrolledIndex, Step.micPermission.rawValue) + XCTAssertEqual(scrolledIndex, 2) } func test_secondButtonAction_중간스텝에서_이전버튼을_누르면_이전스텝으로_이동한다() async { @@ -187,7 +190,8 @@ final class OnBoardingViewModelTests: XCTestCase { await sut.mockSTTRepo.setCheckResult(.notDetermined) await sut.mockSTTRepo.setRequestResult(.success(.authorized)) - sut.viewModel.syncPageState(nextStep: Step.micPermission.rawValue) + let micPermissionIndex = sut.viewModel.steps.firstIndex(of: .micPermission) ?? 2 + sut.viewModel.syncPageState(nextStep: micPermissionIndex) // 백그라운드 Task가 안전하게 완료될 수 있도록 약간의 딜레이 부여 try? await Task.sleep(nanoseconds: 300_000_000) @@ -197,7 +201,7 @@ final class OnBoardingViewModelTests: XCTestCase { scrolledIndex = nextIndex } - XCTAssertEqual(scrolledIndex, Step.second.rawValue) + XCTAssertEqual(scrolledIndex, 1) } func test_checkModelSupport호출시_지원하는기기이면_modelSupport가true가된다() async { @@ -221,7 +225,10 @@ final class OnBoardingViewModelTests: XCTestCase { sut.mockMLXRepo.downloadResult = .success(()) await sut.viewModel.checkModelSupport() - sut.viewModel.syncPageState(nextStep: Step.download.rawValue) + let downloadIndex = sut.viewModel.steps.firstIndex { if case .download = $0 { return true } + return false + } ?? 3 + sut.viewModel.syncPageState(nextStep: downloadIndex) sut.viewModel.primaryButtonAction { _ in } try? await Task.sleep(nanoseconds: 300_000_000) // 다운로드 완료 비동기 대기 diff --git a/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift b/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift index 09ff1b30..cfc32a8b 100644 --- a/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift +++ b/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift @@ -42,6 +42,10 @@ final class DownloadMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { return OnDeviceStatus(storage: .notDownloaded) } + + func fetchModelSize(model: ChaGokModel) async -> String { + return "약 75 MB" + } } @MainActor diff --git a/Presentation/Tests/Setting/SettingViewModelTests.swift b/Presentation/Tests/Setting/SettingViewModelTests.swift index 8817ffae..01e3869c 100644 --- a/Presentation/Tests/Setting/SettingViewModelTests.swift +++ b/Presentation/Tests/Setting/SettingViewModelTests.swift @@ -71,6 +71,17 @@ final class SettingMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { return checkStatusResult } + + func fetchModelSize(model: ChaGokModel) async -> String { + switch model { + case .whisper: + return "약 75 MB" + case .gemma4_e2b_4bit: + return "약 3.58 GB" + case .none: + return "" + } + } } @MainActor From 81acb753244f00d6bdb250951a8264ec3fd81333 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 19:17:19 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor(presentation):=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=EA=B8=B0=EB=B3=B8=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EB=B7=B0=20iPad=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=ED=99=94=20-=20=EC=9D=B4=EC=A0=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20headline,=20body=EA=B0=80=20=EC=B0=8C?= =?UTF-8?q?=EA=B7=B8=EB=9F=AC=EC=A7=80=EC=A7=80=EC=95=8A=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Component/OnBoarding/OnBoardingCardView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingCardView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingCardView.swift index 6d9b8f20..145b877f 100644 --- a/Presentation/Sources/Component/OnBoarding/OnBoardingCardView.swift +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingCardView.swift @@ -63,12 +63,16 @@ extension OnBoardingCardView { translatesAutoresizingMaskIntoConstraints = false axis = .vertical spacing = Constant.onBoardingContentSpacing - // headline·body는 intrinsic size만 차지하고, // 남는 수직 공간은 imageContainer가 흡수하도록 설정 headlineLabel.setContentHuggingPriority(.required, for: .vertical) bodyLabel.setContentHuggingPriority(.required, for: .vertical) imageContainer.setContentHuggingPriority(.defaultLow, for: .vertical) + + // 텍스트가 어떤 상황에서도 잘리거나 찌그러지지 않도록 높은 압축 저항 우선순위 설정 + headlineLabel.setContentCompressionResistancePriority(.required, for: .vertical) + bodyLabel.setContentCompressionResistancePriority(.required, for: .vertical) + imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) } private func setupHierarchy() { From 7e79f2dc991978385dfbb766e96c2f5ea1d7338e Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 23:53:27 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor(presentation):=20=EC=98=A8?= =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=ED=99=94=EB=A9=B4=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EB=B0=8F=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B0=9C=EC=84=A0=20-=20TimelineGuideLabel?= =?UTF-8?q?=EC=9D=98=20layoutSubviews=EB=A5=BC=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=ED=95=98=EC=97=AC=20iPad=20=EB=93=B1=20=EB=84=93=EC=9D=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=A4=84=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=EA=B0=80=20=EC=9E=98=EB=A6=AC=EC=A7=80=20=EC=95=8A=EA=B3=A0=20?= =?UTF-8?q?=EC=98=AC=EB=B0=94=EB=A5=B8=20=EB=86=92=EC=9D=B4=EB=A1=9C=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EC=A0=84=ED=99=98=20=ED=9A=A8=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20CATransition=20=EB=8C=80=EC=8B=A0=20=EB=B6=80?= =?UTF-8?q?=EB=93=9C=EB=9F=AC=EC=9A=B4=20=EC=9C=84=EB=B0=A9=ED=96=A5=20Sli?= =?UTF-8?q?de=20&=20Fade=20=ED=9A=A8=EA=B3=BC(UIView.animate)=EB=A1=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20OnBoardingDownloadVi?= =?UTF-8?q?ew=EC=9D=98=20spacerView=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EC=A0=95=EB=B0=80=20=EC=A1=B0=EC=9C=A8?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A2=81=EC=9D=80=20=EC=88=98=EC=A7=81=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=84=EC=97=90=EC=84=9C=EB=8F=84=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=EC=98=81=EC=97=AD=EC=9D=84=20=EC=B9=A8=EB=B2=94?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20DownloadOnDeviceViewController=EC=9D=98=20Wi-Fi?= =?UTF-8?q?=20=EA=B6=8C=EC=9E=A5=20=EB=AC=B8=EA=B5=AC=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EA=B8=B0=EC=A4=80=20=EC=83=81=EB=8B=A8=2012pt=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EB=B0=8F=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnDevice/Whisper/WhisperKitProvider.swift | 32 ++--- .../Component/Common/DownloadModelCard.swift | 7 ++ .../OnBoarding/OnBoardingDownloadView.swift | 16 ++- .../OnBoarding/TimelineGuideLabel.swift | 115 ++++++++++++++++++ .../Sources/DesignSystem/Constant.swift | 3 + .../OnBoarding/OnBoardingViewController.swift | 13 +- .../DownloadOnDeviceViewController.swift | 4 +- .../ViewModel/OnBoarding/OnBoardingStep.swift | 2 +- .../OnBoarding/OnBoardingViewModel.swift | 14 +++ 9 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift diff --git a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift index 6d1f2a30..6f5f303f 100644 --- a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift @@ -38,22 +38,22 @@ public actor WhisperKitProvider: WhisperDataSource { AppLogger.info("WhisperKit 추천 모델 : \(recommendedModel)") AppLogger.info("WhisperKit 모델 다운로드 시작") -// #if DEBUG -// // ⚠️ 디버깅용 시뮬레이션: 에러 상황별 핸들링을 안전하게 테스트하기 위한 디버그 스위치입니다. -// try await Task.sleep(nanoseconds: 2 * 1_000_000_000) -// -// // [옵션 1] 네트워크 오류 시뮬레이션 (networkFailed) -// // -> 활성화 시 "네트워크 연결이 유실되었습니다" 문구가 노출됩니다. -// // throw URLError(.notConnectedToInternet) -// -// // [옵션 2] 알 수 없는 시스템 오류 시뮬레이션 (unknown) -// // -> 활성화 시 "다운로드에 실패했습니다" 문구와 상세 에러 문구가 노출됩니다. -// throw NSError( -// domain: "SimulatedErrorDomain", -// code: 999, -// userInfo: [NSLocalizedDescriptionKey: "알 수 없는 기기 내부 디스크 쓰기 오류가 발생했습니다. (Simulated)"] -// ) -// #endif + #if DEBUG + // ⚠️ 디버깅용 시뮬레이션: 에러 상황별 핸들링을 안전하게 테스트하기 위한 디버그 스위치입니다. + try await Task.sleep(nanoseconds: 2 * 1_000_000_000) + + // [옵션 1] 네트워크 오류 시뮬레이션 (networkFailed) + // -> 활성화 시 "네트워크 연결이 유실되었습니다" 문구가 노출됩니다. + // throw URLError(.notConnectedToInternet) + + // [옵션 2] 알 수 없는 시스템 오류 시뮬레이션 (unknown) + // -> 활성화 시 "다운로드에 실패했습니다" 문구와 상세 에러 문구가 노출됩니다. + throw NSError( + domain: "SimulatedErrorDomain", + code: 999, + userInfo: [NSLocalizedDescriptionKey: "알 수 없는 기기 내부 디스크 쓰기 오류가 발생했습니다. (Simulated)"] + ) + #endif let path = try await WhisperKit.download( variant: recommendedModel, diff --git a/Presentation/Sources/Component/Common/DownloadModelCard.swift b/Presentation/Sources/Component/Common/DownloadModelCard.swift index ef67b248..edca4c27 100644 --- a/Presentation/Sources/Component/Common/DownloadModelCard.swift +++ b/Presentation/Sources/Component/Common/DownloadModelCard.swift @@ -75,6 +75,9 @@ final class DownloadModelCard: UIStackView { isLayoutMarginsRelativeArrangement = true addArrangedSubview(modelLabel) + modelLabel.setContentHuggingPriority(.required, for: .vertical) + modelLabel.setContentCompressionResistancePriority(.required, for: .vertical) + switch style { case .default: addArrangedSubview(defaultProgressView) @@ -109,6 +112,8 @@ extension DownloadModelCard { // nameLabel nameLabel.setTypography(text: modelName, style: .body2) nameLabel.textColor = UIColor.gray950 + nameLabel.setContentHuggingPriority(.required, for: .vertical) + nameLabel.setContentCompressionResistancePriority(.required, for: .vertical) // imageView let config: UIImage.SymbolConfiguration = .init(pointSize: 20, weight: .medium) imageView.image = UIImage(systemName: symbolName, withConfiguration: config) @@ -121,6 +126,8 @@ extension DownloadModelCard { let spacer = UIView() // model size sizeLabel.setTypography(text: modelSize, style: .label) + sizeLabel.setContentHuggingPriority(.required, for: .vertical) + sizeLabel.setContentCompressionResistancePriority(.required, for: .vertical) for item in [imageView, nameLabel, spacer, sizeLabel] { container.addArrangedSubview(item) } diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift index 344a473a..3b4b0e40 100644 --- a/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift @@ -37,6 +37,8 @@ final class OnBoardingDownloadView: UIStackView { storage: vm.status.storage, modelSize: vm.modelSize ) + + private lazy var timeLineGuideLabel: TimelineGuideLabel = .init(state: .notDownloaded) /// 남는 수직 공간을 흡수하는 빈 뷰 (OnBoardingCardView의 imageContainer 역할) private let spacerView = UIView() @@ -66,6 +68,7 @@ final class OnBoardingDownloadView: UIStackView { super.updateProperties() let storage = vm.status.storage downloadModelCard.updateStatus(storage, errorMessage: vm.errorMessage) + timeLineGuideLabel.updateLabelState(storage) } } @@ -76,18 +79,29 @@ extension OnBoardingDownloadView { translatesAutoresizingMaskIntoConstraints = false axis = .vertical spacing = Constant.onBoardingContentSpacing - // headline·body는 intrinsic size만 차지하고, // 남는 수직 공간은 imageContainer가 흡수하도록 설정 headlineLabel.setContentHuggingPriority(.required, for: .vertical) bodyLabel.setContentHuggingPriority(.required, for: .vertical) downloadModelCard.setContentHuggingPriority(.required, for: .vertical) + timeLineGuideLabel.setContentHuggingPriority(.required, for: .vertical) + spacerView.setContentHuggingPriority(.defaultLow, for: .vertical) + + // 텍스트 및 카드 찌그러짐 방지 제약조건 추가 + headlineLabel.setContentCompressionResistancePriority(.required, for: .vertical) + bodyLabel.setContentCompressionResistancePriority(.required, for: .vertical) + downloadModelCard.setContentCompressionResistancePriority(.required, for: .vertical) + timeLineGuideLabel.setContentCompressionResistancePriority(.required, for: .vertical) + spacerView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) } private func setupHierarchy() { addArrangedSubview(headlineLabel) addArrangedSubview(bodyLabel) addArrangedSubview(downloadModelCard) + setCustomSpacing(0, after: downloadModelCard) addArrangedSubview(spacerView) + setCustomSpacing(0, after: spacerView) + addArrangedSubview(timeLineGuideLabel) } } diff --git a/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift new file mode 100644 index 00000000..24839ecb --- /dev/null +++ b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift @@ -0,0 +1,115 @@ +import UIKit +import Domain + +final class TimelineGuideLabel: UILabel { + private var timer: Timer? + private var state: OnDeviceStatus.StorageState = .notDownloaded + private var index: Int = 0 + + init( + state: OnDeviceStatus.StorageState, + frame: CGRect = .zero + ) { + self.state = state + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func updateProperties() { + super.updateProperties() + updateState() + } + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + textColor = UIColor.gray950 + numberOfLines = 0 + clipsToBounds = true + } + + func updateLabelState(_ state: OnDeviceStatus.StorageState) { + guard self.state != state else { return } + self.state = state + setNeedsUpdateProperties() + } + + private func updateState() { + switch state { + case .notDownloaded, .downloaded, .failed: + stopTimer() + case .downloading: + startTimer() + } + } + + /// timeLine을 시작합니다. + private func startTimer() { + guard timer == nil else { return } + + // 시작하자마자 첫 텍스트가 바로 보이도록 설정 + index = 0 + updateLabel(animated: false) + + // 4초 주기로 텍스트 롤링 타이머 구동 + timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.index += 1 + self.updateLabel(animated: true) + } + } + } + + /// timeLine의 진행을 멈춥니다. + private func stopTimer() { + timer?.invalidate() + timer = nil + text = "" // 다운로드 중이 아닐 때는 표시하지 않음 + alpha = 1 + transform = .identity + } + + /// 텍스트 업데이트 및 아래에서 위로 올라오는 전환 효과 적용 + private func updateLabel(animated: Bool) { + let nextText = toolTips[index % toolTips.count] + + if animated { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: { + self.alpha = 0 + self.transform = CGAffineTransform(translationX: 0, y: -12) + }) { [weak self] _ in + guard let self else { return } + self.text = nextText + self.setTypography(text: nextText, style: .body3, textAlignment: .center) + self.transform = CGAffineTransform(translationX: 0, y: 12) + + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: { + self.alpha = 1 + self.transform = .identity + }) + } + } else { + self.text = nextText + setTypography(text: text, style: .body3, textAlignment: .center) + } + } +} + +extension TimelineGuideLabel { + /// timeline을 통해 보여줄 데이터 + private var toolTips: [String] { + [ + "차곡의 모든 인공지능 요약 연산은 기기 안에서만 처리돼요.\n내 소중한 대화 내용이 절대 외부 서버로 전송되거나 유출되지 않으니 안심하세요!", + "다운로드가 완료되면 인터넷이 연결되지 않은 비행기 모드나 데이터가 터지지 않는 깊은 산속에서도 음성 인식과 요약 기능을 그대로 사용할 수 있어요.", + "길게 녹음된 음성을 처음부터 다 들을 필요 없어요.\n차곡의 AI가 회의나 대화의 핵심 내용과 키워드만 일목요연하게 요약해 줍니다.", + "실수로 삭제한 소중한 기록은 휴지통 폴더에 보관돼요.\n완전히 지워지기 전이라면 언제든 터치 한 번으로 복구할 수 있어요.", + "폴더 기능을 활용해 회의록, 아이디어 노트, 강의 녹음 등 주제별로 정리해 보세요.\n정돈된 분류는 나중에 기록을 다시 꺼내볼 때 시간을 절약해 줘요.", + "안정적인 설치를 위해 기기에 약 4GB 이상의 여유 공간이 필요해요.\n다운로드가 원활하지 않다면 기기의 저장 공간을 직접 정리해 주세요." + ] + } +} diff --git a/Presentation/Sources/DesignSystem/Constant.swift b/Presentation/Sources/DesignSystem/Constant.swift index 42f2898e..43b42a53 100644 --- a/Presentation/Sources/DesignSystem/Constant.swift +++ b/Presentation/Sources/DesignSystem/Constant.swift @@ -100,6 +100,9 @@ public extension Constant { /// OnBoarding 페이지네이션과 페이징뷰 사이의 상단 여백 (105) static let onBoardingPagingViewTopMargin: CGFloat = 105 + + /// OnBoarding IPad 용 페징 뷰 사이 상단 여백 ( 52 ) + static let onBoardingPagingViewTopMarginForiPad: CGFloat = 52 /// OnBoarding 페이징뷰와 버튼 사이의 하단 여백 (16) static let onBoardingPagingViewBottomMargin: CGFloat = 16 diff --git a/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift index e4345a2e..4906bb24 100644 --- a/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift +++ b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift @@ -69,12 +69,14 @@ public final class OnBoardingViewController: ViewController { // 버튼 상태 업데이트 primaryButton.configuration?.title = vm.primaryButtonTitle primaryButton.isHidden = !vm.isPrimaryButtonEnabled - secondButton.configuration?.title = vm.secondButtonTitle - secondButton.isUserInteractionEnabled = vm.isSecondButtonEnabled primaryButton.configuration?.baseBackgroundColor = vm .isPrimaryButtonBgColor ? UIColor.point600 : UIColor.point200 .withAlphaComponent(Constant.backgroundOpacity) - primaryButton.configuration?.baseForegroundColor = UIColor.gray900 + secondButton.configuration?.title = vm.secondButtonTitle + secondButton.isUserInteractionEnabled = vm.isSecondButtonEnabled + secondButton.configuration?.background.backgroundColor = vm + .isSecondButtonBgColor ? UIColor.point600 : .clear + secondButton.configuration?.baseForegroundColor = vm.isSecondButtonBgColor ? UIColor.gray950 : UIColor.gray750 // paginView pagingView.isScrollEnabled = vm.scrollEnabled // pagenation 업데이트 @@ -129,11 +131,14 @@ public final class OnBoardingViewController: ViewController { // MARK: - Constraint private func setupCardConstraint() { + let isPad = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.model.lowercased().contains("ipad") + let topConstant: CGFloat = isPad ? Constant.onBoardingPagingViewTopMarginForiPad : Constant.onBoardingPagingViewTopMargin + NSLayoutConstraint.activate([ // 페이징 뷰 위치 제약 (페이지네이션과 다음 버튼 사이) pagingView.topAnchor.constraint( equalTo: pagenation.bottomAnchor, - constant: Constant.onBoardingPagingViewTopMargin + constant: topConstant ), pagingView.leadingAnchor.constraint( equalTo: view.leadingAnchor, diff --git a/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift b/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift index 09477799..0fe75a49 100644 --- a/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift +++ b/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift @@ -128,9 +128,11 @@ public final class DownloadOnDeviceViewController: UIViewController, Alertable { downloadModelCard.trailingAnchor.constraint(equalTo: subTitleLabel.trailingAnchor), // subTitleLabel2 - subTitle2Label.topAnchor.constraint(equalTo: infoBox.bottomAnchor, constant: 24), + subTitle2Label.bottomAnchor.constraint(equalTo: bottomArea.topAnchor, constant: -12), subTitle2Label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), subTitle2Label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + subTitle2Label.topAnchor.constraint(greaterThanOrEqualTo: infoBox.bottomAnchor, constant: 12), + subTitle2Label.topAnchor.constraint(greaterThanOrEqualTo: downloadModelCard.bottomAnchor, constant: 12), // bottomArea bottomArea.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift index 327259ef..6104bc39 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift @@ -43,7 +43,7 @@ enum Step: Equatable { case .download: OnBoardingItem( headline: "기기에서 바로 작동하도록,\n몇 가지를 준비할게요.", - body: "녹음과 요약을 기기 안에서 처리하기 위해\n필요한 모델을 다운로드해요.\nWi-Fi 환경을 권장하며\n나중에 설정에서도 다운로드할 수 있어요." + body: "녹음과 요약을 기기 안에서 처리하기 위해\n필요한 모델을 다운로드해요. Wi-Fi 환경을 권장하며 나중에 설정에서도 다운로드할 수 있어요." ) case .finish: OnBoardingItem( diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift index 1bf5657f..e3c5fac1 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift @@ -124,6 +124,20 @@ public final class OnBoardingViewModel { return false } } + + var isSecondButtonBgColor: Bool { + switch currentStep { + case .download: + switch status.storage { + case .downloading: + return true + default: + return false + } + default: + return false + } + } // MARK: - Setters From f653972572a9d9bb8eae29b1d942849e0059c872 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Thu, 11 Jun 2026 23:54:07 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20swiftformat=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnBoarding/OnBoardingDownloadView.swift | 2 +- .../OnBoarding/TimelineGuideLabel.swift | 36 +++++++++---------- .../Sources/DesignSystem/Constant.swift | 2 +- .../OnBoarding/OnBoardingViewController.swift | 3 +- .../OnBoarding/OnBoardingViewModel.swift | 2 +- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift index 3b4b0e40..edd15123 100644 --- a/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift @@ -37,7 +37,7 @@ final class OnBoardingDownloadView: UIStackView { storage: vm.status.storage, modelSize: vm.modelSize ) - + private lazy var timeLineGuideLabel: TimelineGuideLabel = .init(state: .notDownloaded) /// 남는 수직 공간을 흡수하는 빈 뷰 (OnBoardingCardView의 imageContainer 역할) diff --git a/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift index 24839ecb..d35bd8df 100644 --- a/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift +++ b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift @@ -1,11 +1,11 @@ -import UIKit import Domain +import UIKit final class TimelineGuideLabel: UILabel { private var timer: Timer? private var state: OnDeviceStatus.StorageState = .notDownloaded private var index: Int = 0 - + init( state: OnDeviceStatus.StorageState, frame: CGRect = .zero @@ -14,30 +14,30 @@ final class TimelineGuideLabel: UILabel { super.init(frame: frame) setup() } - + @available(*, unavailable) required init?(coder: NSCoder) { nil } - + override func updateProperties() { super.updateProperties() updateState() } - + private func setup() { translatesAutoresizingMaskIntoConstraints = false textColor = UIColor.gray950 numberOfLines = 0 clipsToBounds = true } - + func updateLabelState(_ state: OnDeviceStatus.StorageState) { guard self.state != state else { return } self.state = state setNeedsUpdateProperties() } - + private func updateState() { switch state { case .notDownloaded, .downloaded, .failed: @@ -46,15 +46,15 @@ final class TimelineGuideLabel: UILabel { startTimer() } } - + /// timeLine을 시작합니다. private func startTimer() { guard timer == nil else { return } - + // 시작하자마자 첫 텍스트가 바로 보이도록 설정 index = 0 updateLabel(animated: false) - + // 4초 주기로 텍스트 롤링 타이머 구동 timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in Task { @MainActor in @@ -64,7 +64,7 @@ final class TimelineGuideLabel: UILabel { } } } - + /// timeLine의 진행을 멈춥니다. private func stopTimer() { timer?.invalidate() @@ -73,28 +73,28 @@ final class TimelineGuideLabel: UILabel { alpha = 1 transform = .identity } - + /// 텍스트 업데이트 및 아래에서 위로 올라오는 전환 효과 적용 private func updateLabel(animated: Bool) { let nextText = toolTips[index % toolTips.count] - + if animated { UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: { self.alpha = 0 self.transform = CGAffineTransform(translationX: 0, y: -12) }) { [weak self] _ in guard let self else { return } - self.text = nextText - self.setTypography(text: nextText, style: .body3, textAlignment: .center) - self.transform = CGAffineTransform(translationX: 0, y: 12) - + text = nextText + setTypography(text: nextText, style: .body3, textAlignment: .center) + transform = CGAffineTransform(translationX: 0, y: 12) + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: { self.alpha = 1 self.transform = .identity }) } } else { - self.text = nextText + text = nextText setTypography(text: text, style: .body3, textAlignment: .center) } } diff --git a/Presentation/Sources/DesignSystem/Constant.swift b/Presentation/Sources/DesignSystem/Constant.swift index 43b42a53..260dba3b 100644 --- a/Presentation/Sources/DesignSystem/Constant.swift +++ b/Presentation/Sources/DesignSystem/Constant.swift @@ -100,7 +100,7 @@ public extension Constant { /// OnBoarding 페이지네이션과 페이징뷰 사이의 상단 여백 (105) static let onBoardingPagingViewTopMargin: CGFloat = 105 - + /// OnBoarding IPad 용 페징 뷰 사이 상단 여백 ( 52 ) static let onBoardingPagingViewTopMarginForiPad: CGFloat = 52 diff --git a/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift index 4906bb24..d54334ef 100644 --- a/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift +++ b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift @@ -132,7 +132,8 @@ public final class OnBoardingViewController: ViewController { private func setupCardConstraint() { let isPad = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.model.lowercased().contains("ipad") - let topConstant: CGFloat = isPad ? Constant.onBoardingPagingViewTopMarginForiPad : Constant.onBoardingPagingViewTopMargin + let topConstant: CGFloat = isPad ? Constant.onBoardingPagingViewTopMarginForiPad : Constant + .onBoardingPagingViewTopMargin NSLayoutConstraint.activate([ // 페이징 뷰 위치 제약 (페이지네이션과 다음 버튼 사이) diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift index e3c5fac1..2b30d8c7 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift @@ -124,7 +124,7 @@ public final class OnBoardingViewModel { return false } } - + var isSecondButtonBgColor: Bool { switch currentStep { case .download: From 5752b34473c36e6866fcaf1a22c11dd0ed8e5841 Mon Sep 17 00:00:00 2001 From: Kim yonghae Date: Fri, 12 Jun 2026 00:23:36 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor(data):=20URLError=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20=EC=A3=BC=EC=84=9D=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnDevice/Whisper/WhisperKitProvider.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift index 6f5f303f..cc9bc69f 100644 --- a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift @@ -38,22 +38,22 @@ public actor WhisperKitProvider: WhisperDataSource { AppLogger.info("WhisperKit 추천 모델 : \(recommendedModel)") AppLogger.info("WhisperKit 모델 다운로드 시작") - #if DEBUG - // ⚠️ 디버깅용 시뮬레이션: 에러 상황별 핸들링을 안전하게 테스트하기 위한 디버그 스위치입니다. - try await Task.sleep(nanoseconds: 2 * 1_000_000_000) - - // [옵션 1] 네트워크 오류 시뮬레이션 (networkFailed) - // -> 활성화 시 "네트워크 연결이 유실되었습니다" 문구가 노출됩니다. - // throw URLError(.notConnectedToInternet) - - // [옵션 2] 알 수 없는 시스템 오류 시뮬레이션 (unknown) - // -> 활성화 시 "다운로드에 실패했습니다" 문구와 상세 에러 문구가 노출됩니다. - throw NSError( - domain: "SimulatedErrorDomain", - code: 999, - userInfo: [NSLocalizedDescriptionKey: "알 수 없는 기기 내부 디스크 쓰기 오류가 발생했습니다. (Simulated)"] - ) - #endif + // #if DEBUG + // // ⚠️ 디버깅용 시뮬레이션: 에러 상황별 핸들링을 안전하게 테스트하기 위한 디버그 스위치입니다. + // try await Task.sleep(nanoseconds: 2 * 1_000_000_000) + + // // [옵션 1] 네트워크 오류 시뮬레이션 (networkFailed) + // // -> 활성화 시 "네트워크 연결이 유실되었습니다" 문구가 노출됩니다. + // // throw URLError(.notConnectedToInternet) + + // // [옵션 2] 알 수 없는 시스템 오류 시뮬레이션 (unknown) + // // -> 활성화 시 "다운로드에 실패했습니다" 문구와 상세 에러 문구가 노출됩니다. + // throw NSError( + // domain: "SimulatedErrorDomain", + // code: 999, + // userInfo: [NSLocalizedDescriptionKey: "알 수 없는 기기 내부 디스크 쓰기 오류가 발생했습니다. (Simulated)"] + // ) + // #endif let path = try await WhisperKit.download( variant: recommendedModel,