Skip to content
Merged
4 changes: 3 additions & 1 deletion App/Sources/Coordinator/MainCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ 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()]
Comment thread
Kimyonhae marked this conversation as resolved.
sheet.prefersGrabberVisible = true
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,33 @@ 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
}
Comment thread
Kimyonhae marked this conversation as resolved.

public func download(progressHandler: @Sendable @escaping (Progress) -> Void) async throws {
let recommendedModel = WhisperKit.recommendedModels().default
self.recommendedModel = recommendedModel
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,
Expand Down
3 changes: 3 additions & 0 deletions Data/Sources/Interfaces/Whisper/WhisperDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import WhisperKit

/// Whisper STT 모델 엔진을 제어하고 음성 전사 데이터를 제공하는 데이터 소스 인터페이스.
public protocol WhisperDataSource: Sendable {
/// 추천 모델의 Variant 정보를 가져옵니다.
func fetchModelVariant() async -> ModelVariant

/// 모델의 다운로드 경로를 전달합니다.
func getDownloadPath() async throws(WhisperDataSourceError) -> URL

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift
Original file line number Diff line number Diff line change
@@ -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)
/// 모델을 제거합니다.
Expand Down
7 changes: 7 additions & 0 deletions Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, OnDeviceRepositoryError> = .success(())
public var deleteResult: Result<OnDeviceStatus, DeleteOnDeviceRepositoryError> = .success(OnDeviceStatus(
storage: .notDownloaded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OnDeviceStatus> {
actualSubscribeCallCount += 1
subscribedModel = model
Expand Down
29 changes: 26 additions & 3 deletions Presentation/Sources/Component/Common/DownloadModelCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -17,13 +25,15 @@ final class DownloadModelCard: UIStackView {
style: ProgressStyle,
storage: OnDeviceStatus.StorageState,
errorMessage: String? = nil,
modelSize: String = "",
frame: CGRect = .zero
) {
self.symbolName = symbolName
self.modelName = modelName
self.style = style
self.storage = storage
self.errorMessage = errorMessage
self.modelSize = modelSize
super.init(frame: frame)
setup()
}
Expand Down Expand Up @@ -65,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)
Expand Down Expand Up @@ -92,13 +105,15 @@ extension DownloadModelCard {
let imageView = UIImageView()
let nameLabel = UILabel()

for item in [container, imageView, nameLabel] {
for item in [container, imageView, nameLabel, sizeLabel] {
item.translatesAutoresizingMaskIntoConstraints = false
}

// 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)
Expand All @@ -109,7 +124,11 @@ extension DownloadModelCard {
container.spacing = 8
// spacer
let spacer = UIView()
for item in [imageView, nameLabel, spacer] {
// 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)
}

Expand All @@ -120,9 +139,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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ final class OnBoardingDownloadView: UIStackView {
symbolName: "externaldrive",
modelName: "Gemma-4",
style: .immutable,
storage: vm.status.storage
storage: vm.status.storage,
modelSize: vm.modelSize
)

private lazy var timeLineGuideLabel: TimelineGuideLabel = .init(state: .notDownloaded)

/// 남는 수직 공간을 흡수하는 빈 뷰 (OnBoardingCardView의 imageContainer 역할)
private let spacerView = UIView()

Expand Down Expand Up @@ -65,6 +68,7 @@ final class OnBoardingDownloadView: UIStackView {
super.updateProperties()
let storage = vm.status.storage
downloadModelCard.updateStatus(storage, errorMessage: vm.errorMessage)
timeLineGuideLabel.updateLabelState(storage)
}
}

Expand All @@ -75,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)
}
}
Loading
Loading