diff --git a/App/Sources/AppDIContainer.swift b/App/Sources/AppDIContainer.swift index a419184a..a711ba6a 100644 --- a/App/Sources/AppDIContainer.swift +++ b/App/Sources/AppDIContainer.swift @@ -29,6 +29,9 @@ public final class AppDIContainer { private lazy var mlxSummaryRepository = DefaultMLXSummaryRepository( provider: mlxProvider ) + private lazy var mlxGrammarRepository = DefaultMLXGrammarRepository( + provider: mlxProvider + ) private lazy var whisperProvider = WhisperKitProvider( storageService: storageService, languageRepository: languageRepository @@ -55,7 +58,8 @@ public final class AppDIContainer { voiceNoteRepository: voiceNoteRepository, sttRepository: sttWhisperRepository, summaryRepository: mlxSummaryRepository, - languageRepository: languageRepository + languageRepository: languageRepository, + grammarRepository: mlxGrammarRepository ) /// UseCase diff --git a/App/Sources/AppIntentsRegistry.swift b/App/Sources/AppIntentsRegistry.swift new file mode 100644 index 00000000..b5cf8dab --- /dev/null +++ b/App/Sources/AppIntentsRegistry.swift @@ -0,0 +1,13 @@ +import AppIntents +import Presentation + +/// 메인 앱에서 Presentation 프레임워크의 AppIntent(LiveActivityIntent)를 +/// 시스템이 인식할 수 있도록 등록합니다. +/// +/// LiveActivityIntent는 메인 앱 프로세스에서 실행되므로, +/// 앱 타겟에서도 프레임워크의 Intent 메타데이터를 참조해야 합니다. +struct AppIntentsRegistry: AppIntentsPackage { + static var includedPackages: [any AppIntentsPackage.Type] { + [PresentationAppIntents.self] + } +} diff --git a/App/Sources/Debug/DebugSeeder.swift b/App/Sources/Debug/DebugSeeder.swift index 1b853637..f47df3f7 100644 --- a/App/Sources/Debug/DebugSeeder.swift +++ b/App/Sources/Debug/DebugSeeder.swift @@ -12,23 +12,26 @@ let voiceNoteRepository: any VoiceNoteRepository func seedIfNeeded() { - let defaults = UserDefaults.standard - guard !defaults.bool(forKey: Self.didSeedKey) else { - AppLogger.debug("시드 데이터가 이미 존재합니다. 스킵.") - return - } - do { let folders = try folderRepository.fetchAll() guard folders.contains(where: { $0.kind == .default }) else { AppLogger.debug("기본 폴더 미존재. 온보딩 이후 다시 시도합니다.") return } + + // 디버그 빌드 시 매번 실행하여 최신 시드 데이터를 갱신합니다. + // 중복 및 이전 시드를 방지하기 위해 기존 시드 폴더를 먼저 삭제합니다 (Core Data cascade 삭제됨). + let seedFolderNames = ["업무", "개인", "학습", "회의록"] + for folder in folders { + if seedFolderNames.contains(folder.name) { + try folderRepository.delete(id: folder.id) + } + } + try performSeed() - defaults.set(true, forKey: Self.didSeedKey) - AppLogger.info("시드 데이터 생성 완료") + AppLogger.info("시드 데이터 초기화 및 재설정 완료") } catch { - AppLogger.error("시드 데이터 생성 실패: \(error)") + AppLogger.error("시드 데이터 초기화 실패: \(error)") } } @@ -236,6 +239,15 @@ summaryLines: [], keywords: ["장애", "인시던트", "포스트모템"], analysisState: .transcriptionFailed + ), + Spec( + folderID: personalFolder.id, + title: "일정 및 버그 관련 푸념 메모 (문법 교정 테스트용)", + createdAt: now.addingTimeInterval(-h * 2), + texts: SeedContent.grammarCheckTest, + summaryLines: [], + keywords: [], + analysisState: .transcribed ) ] @@ -245,7 +257,9 @@ } private func createSeededNote(spec: Spec) throws { - let sections = SeedContent.buildSections(texts: spec.texts) + let sections = spec.title.contains("문법") + ? SeedContent.buildLongSections(texts: spec.texts, totalDuration: 3600) + : SeedContent.buildSections(texts: spec.texts) let duration = (sections.last?.timestamp ?? 0) + 3.0 let audioPath = try makeSilentAudioFile(duration: max(duration, 2.5)) let record = VoiceRecord( @@ -288,14 +302,18 @@ private func shouldIncludeTranscript(for state: AnalysisState) -> Bool { switch state { - case .pending, .transcribing, .transcriptionFailed: + case .pending, .transcribing, .transcriptionFailed, .waiting: return false - case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed: + case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed, .grammarCheckFailed, + .grammarChecked, .grammarChecking: return true } } private func makeSilentAudioFile(duration: Double) throws -> String { + // 디버그용 무음 파일이므로 실제 파일 길이는 최대 5초로 제한하여 + // 메인 스레드 병목 및 과도한 메모리/디스크 사용을 방지합니다. + let physicalDuration = min(duration, 5.0) let directory = "VoiceRecords" let fileName = "seed-\(UUID().uuidString).m4a" let docURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] @@ -318,7 +336,7 @@ commonFormat: .pcmFormatFloat32, interleaved: false ) - let frameCount = AVAudioFrameCount(file.processingFormat.sampleRate * duration) + let frameCount = AVAudioFrameCount(file.processingFormat.sampleRate * physicalDuration) guard let buffer = AVAudioPCMBuffer( pcmFormat: file.processingFormat, frameCapacity: frameCount diff --git a/App/Sources/Debug/SeedContent.swift b/App/Sources/Debug/SeedContent.swift index 64d32b5a..848a1840 100644 --- a/App/Sources/Debug/SeedContent.swift +++ b/App/Sources/Debug/SeedContent.swift @@ -13,6 +13,18 @@ return result } + static func buildLongSections(texts: [String], totalDuration: TimeInterval) -> [TranscriptSection] { + var result: [TranscriptSection] = [] + guard !texts.isEmpty else { return result } + let interval = totalDuration / Double(texts.count) + var timestamp: TimeInterval = 0 + for text in texts { + result.append(TranscriptSection(timestamp: timestamp, text: text)) + timestamp += interval + } + return result + } + // MARK: - 업무 static let weeklyMeeting: [String] = [ @@ -503,5 +515,128 @@ "포스트모템은 이번 주 금요일 오전 11시에 진행 예정이고 회의 기록은 Wiki에 공유합니다.", "포스트모템 전까지 해야 할 작업 리스트입니다." ] + + static let grammarCheckTest: [String] = [ + "자 어... 오늘 셰션은요 ios앱 성능 최적화랑 메무리 관리에대해성 본격적으로 얘기해볼려고 함니다.", + "최그네 울이 프로젝트가 기능이 막 만하 지면서 기기에서 메무리 경고도 자주 뜨구, 가끔 특정 화면에서 버벅인다는 피드백이 들어왔잔아요.", + "구래서 오늘 한시간 동안 메무리누수랑 메인스래드 병목을 어떡게 잡을지 코도를 보면서 심도있개 다뤄보갯슴니다.", + "우선 메무리 관리의 가장 기본이 돼는 ARC, 그러니까 아웃토메틱 레퍼런스 카운팅부터 가볍개 복습하구 가죠.", + "다들 아시다시피 Swift는 가비지컬랙터가 업구 ARC를 통해성 컴파일타임에 참조카운트를 계산해성 메무리를 해재함니다.", + "근대 이게 되개 편리하긴 한대, 개발자가 실수로 순환참조우를 만들어버리묜 메무리가 해재되지 안고 계속 남아잇는 누수가 발생해여.", + "가장 흔하게 실수하는 부분이 바로 클로저 내부에서 self를 캡쳐할때 weak이나 unowned를 빼먹는 경우임니다.", + "특히 그... escaping 클로저나 뷰모델의 비동기 작업 내에서 self를 참조우할때 순환참조우가 아주 쉽게 발생하죠.", + "예룰 들어서 네트워크 요청을 보내고 응답을 처리하는 클로저에서 self를 강하게 참조우하면, 요청이 끝날때까지 뷰컨트롤러가 메무리에서 안 내려감니다.", + "마냑 네트워크 요청이 실패하거나 대기 시간이 엄청 길어지면 그동안 사용자는 화면을 나갓는데도 메무리엔 계속 남아잇는 거죠.", + "이런 현상을 방지할려면 클로저 캡쳐 리스트에다가 괄호 열고 weak self 괄호 닫고를 꼭 명시해 줘 야 함니다.", + "근대 무조건 weak self만 쓰는 게 답은 아니여. 상황에 따라서는 unowned self가 더 적합할때도 잇슴니다.", + "unowned는 참조우하는 객체가 절대 nil이 되지 안는다고 보장할때 쓰는 건데, 마냑 객체가 이미 해재된 상태에서 접근하면 앱이 바로 크래시 남니다.", + "소위 말하는 댕글링 포인터 에러가 발생하는 건대, 그래서 실무에서는 안전성을 위해성 웬만하면 weak self를 쓰는 걸 권장해여.", + "자, 그럼 실제로 울이 앱에 메무리 누수가 잇는지 어떡게 찾아낼 수 잇을까요?", + "가장 좋은 도구는 역시 Xcode에 내장되어 잇는 Instruments의 Leaks와 Allocations 템플릿임니다.", + "Leaks 도구를 실행하구 앱을 조작하다 보면, 메무리 누수가 발생하는 시점에 빨간색 아이콘으로 경고가 표시됨니다.", + "그때 콜 스택을 추적해 보면 어느 클래스의 어느 코도에서 누수가 시작되엇는지 아주 정확하게 짚어줌니다.", + "그리구 Allocations 도구를 쓰면 전체 메무리 사용량의 흐름을 그래프로 볼 수 잇어서, 메무리가 지속적으로 우상향하는지 모니터링할 수 잇어요.", + "마냑 화면을 열었다가 닫았는데도 메무리 사용량이 원래대로 돌아오지 안고 계단식으로 계속 증가한다면, 100퍼센트 누수가 잇는 겁니다.", + "또 다른 꿀팁은 Xcode의 디버그 메무리 그래프 기능을 활용하는 거에여. 디버그 중에 버튼 하나만 누르면 현재 메무리 관계도가 시각적으로 그려짐니다.", + "여기서 두 객체가 서로를 가리키며 순환참조우를 이루고 잇는 모습을 한눈에 볼 수 잇어서 아주 편리해여.", + "이제 메무리 누수를 잡았으면, 다음으로 중요한 건 메인스래드 병목을 해결하는 검니다.", + "iOS에서 모든 UI 업데이트는 반드시 메인스래드에서만 수행되어야 한다는 대원칙이 잇습니다.", + "마냑 무거운 데이터 연산이나 이미지 디코딩 같은 작업을 메인스래드에서 해버리면 화면이 버벅이는 프레임 드랍이 발생해여.", + "이걸 방지하기 위해성 Swift 6의 새로운 동시성 모델인 async-await와 Task를 적극적으로 도입해야 함니다.", + "무거운 작업은 글로벌 액터나 백그라운드 스래드로 보내고, UI를 그릴 때만 @MainActor 격리를 통해성 메인스래드로 돌아오게 설계해야 하죠.", + "특히 메인 액터 격리를 과도하게 클래스 전체에 지정하면 오히려 모든 작업이 메인스래드로 몰리는 역효과가 날 수 있으니 주의해야 해여.", + "자, 이번에는 SwiftUI의 렌더링 최적화에 대해성도 짚고 넘어가 보갯습니다.", + "SwiftUI는 선언형 뷰라서 상태가 바뀔 때마다 body 프로퍼티가 통째로 다시 호출되는 구조임니다.", + "이게 편리하긴 한대, 의도치 안게 너무 만은 뷰가 불필요하게 리렌더링되면 심각한 성능 저하로 이어져여.", + "예를 들어 상위 뷰의 @State 변수가 바뀔 때 하위 뷰가 굳이 그 값을 쓰지 안는데도 같이 그려지는 현상이 발생함니다.", + "이걸 막을려면 뷰를 최대한 잘게 쪼개고, 상태의 전파 범위를 최소화해야 함니다.", + "그리구 Observable 프로토콜을 사용하면 상태 변화에 따른 재랜더링 대상을 Swift가 컴파일 타임에 정확하게 추적해 줘서 성능에 매우 유리함니다.", + "또한 List나 LazyVStack 같은 컴포넌트를 쓸 때도 주의할 점이 만아요.", + "LazyVStack은 화면에 보이는 뷰만 렌더링하지만, 스크롤을 빠르게 할 때 셀이 급격히 생성되면서 순간적인 버벅임이 생길 수 잇습니다.", + "이럴 때는 각 셀의 레이아웃을 단순화하고, 셀 내부에서 무거운 계산이나 이미지 처리가 일어나지 안도록 사전에 캐싱 처리를 해두어야 함니다.", + "이미지 캐싱 얘기가 나와성 말인데, 모바일 앱에서 메무리를 가장 마니 잡아먹는 주범이 바로 이미지임니다.", + "만은 초보 개발자분들이 실수하는 게, 화면에 50x50 크기로 보여줄 이미지를 서버에서 3000x3000 크기 그대로 받아서 메무리에 올리는 경우임니다.", + "이미지의 크기는 파일 용량이 아니라 픽셀 해상도에 비례해서 메무리를 차지함니다. 3000x3000 이미지는 무려 36메가바이트의 메무리를 먹게 되죠.", + "그리구 서버에서 썸네일 이미지를 따로 제공받거나, 클라이언트 앱 내에서 뷰 크기에 맞게 다운샘플링하는 프로세스가 꼭 들어가야 함니다.", + "그다음으로 코어 데이터 최적화도 빼놓을 수 없는 핵심 영역임니다.", + "코어 데이터에서 대량의 레코드를 삽입하거나 업데이트할때, 한 건씩 컨텍스트를 저장(save)하면 디스크 I/O 병목이 발생함니다.", + "이럴 때는 NSBatchInsertRequest나 NSBatchUpdateRequest를 사용해서 영속성 스토어에 직접 쿼리를 날려야 훨씬 빠름니다.", + "그리구 데이터를 조회할때도 fetch limit과 fetch offset을 적절히 설정해서 페이징 처리를 구현해야 하고요.", + "불필요한 관계(Relationship) 데이터까지 한 번에 로드하지 안도록 relationshipKeyPathsForPrefetching을 적절히 활용하는 것도 좋슴니다.", + "네트워크 영역에서의 최적화도 간단히 언급하고 갈개요.", + "모든 네트워크 요청은 데이터 통신 요금과 배터리를 소모하므로, 캐싱 정책을 적극적으로 활용해야 함니다.", + "URLSession을 쓸때 URLCache를 설정해서 로컬에 응답을 저장해두면, 동일한 요청에 대해성 네트워크를 타지 안고 즉시 반환할 수 있죠.", + "특히 정적인 이미지나 설정 데이터 파일은 캐시 만료 시간을 길게 잡아서 네트워크 요청 자체를 최소화하는 게 상책임니다.", + "이제 앱의 기동 시간, 즉 앱 스타트업 타임 최적화에 대해성 이야기해 봅시다.", + "사용자가 앱 아이콘을 누르고 첫 화면이 뜰 때까지 2초 이상 걸리면 사용자 경험이 극도로 나빠짐니다.", + "스타트업 타임은 크게 dyld 단계(메인 함수 실행 전)와 main 단계(앱 델리게이트 완료 전)로 나뉨니다.", + "dyld 단계를 줄일려면 앱이 로드하는 외부 동적 라이브러리(Dynamic Library)의 개수를 최소화하고, 가급적 정적 라이브러리를 써야 함니다.", + "그리구 main 단계를 줄일려면 AppDelegate의 didFinishLaunchingWithOptions 메서드 내에서 하는 초기화 작업을 최소화해야 해여.", + "서드파티 SDK 초기화나 로컬 데이터베이스 웜업 작업은 메인스래드에서 동기적으로 실행하지 말고, 비동기로 처리하거나 백그라운드 Task로 빼야 함니다.", + "자, 지금까지 다룬 내용들을 실제로 어떻게 지속적으로 관리할 수 잇을까요?", + "가장 추천하는 방법은 CI/CD 파이프라인에 성능 측정 자동화 단계를 추가하는 것임니다.", + "Xcode 16부터는 XCTest를 통해성 CPU 사용량, 메무리 할당량, 앱 기동 시간 등을 자동 테스트하고 임계치를 넘으면 빌드를 실패하게 만들 수 잇습니다.", + "이렇게 자동화된 시스템을 갖춰두면 개발자가 코딩하다가 실수로 메무리 누수를 내더라도 커밋 단계에서 바로 걸러낼 수 있죠.", + "자, 오늘 준비한 이론 내용은 여기까지고요, 이제 남은 시간 동안 여러분의 개별 프로젝트 코드베이스를 보면서 같이 리팩토링을 해보겠습니다.", + "먼저 민수 님이 작업 중이신 동기화 모듈 코도부터 같이 화면에 띄워놓고 메무리 릭이 발생하는 부분을 분석해 보죠.", + "여기를 보시면 델리게이트 패턴을 구현하면서 프로토콜 타입의 변수를 weak으로 선언하지 안으셨네요. 이 부분이 릭의 원인임니다.", + "프로토콜을 weak으로 선언할려면 해당 프로토콜이 AnyObject를 상속받도록 정의해야 함니다. 클래스 전용 프로토콜로 만드는 거죠.", + "아, 그렇게 하니까 컴파일 에러도 사라지고 메무리 그래프에서도 순환참조우 고리가 끊어진 걸 볼 수 있네요. 아주 좋슴니다.", + "그다음으로 지은 님이 공유해 주신 이미지 로딩 뷰 컴포넌트를 보겠슴니다.", + "스크롤을 빠르게 할때 화면이 순간적으로 뚝뚝 끊기는 현상이 있는데, 이미지 디코딩 작업을 메인스래드에서 처리하고 있기 때문임니다.", + "SwiftUI의 AsyncImage는 내부적으로 이 디코딩 캐싱 처리가 미흡해서, 대량의 고해상도 이미지를 보여줄 때는 커스텀 이미지 로더를 쓰는 게 안전해여.", + "우리가 직접 캐싱 메커니즘이 들어간 이미지 뷰를 만들어서 적용해 보면 훨씬 부드러워질 검니다.", + "실제로 다운샘플링 옵션을 켜고 캐시를 적용하니까 스크롤 프레임이 60프레임으로 아주 안정적으로 유지되는 걸 볼 수 잇습니다.", + "이처럼 아주 작은 최적화 기법 몇 가지만 제대로 알고 적용해도 앱의 사용성이 180도 달라짐니다.", + "특히 배터리 소모량이나 기기 발열 문제도 결국 CPU와 메무리 최적화와 직결되어 있기 때문에 아주 중요한 작업이죠.", + "오늘 다룬 내용 중 질문 있으신 분 편하게 말씀해 주세요.", + "네, 영희 님 질문 주셨는데, weak self 대신 unowned self를 쓰면 성능상 이점이 체감될 정도로 큰가요라는 질문이네요.", + "이론적으로는 weak은 ARC 테이블을 통해성 옵셔널 관리를 하므로 약간의 오버헤드가 있지만, 실제 성능 체감은 불가능한 수준임니다.", + "따라서 성능 이점 때문에 unowned를 쓰는 것은 위험성 대비 실익이 전혀 없으므로 무조건 weak self를 쓰는 게 맞습니다.", + "철수 님은 썸네일 다운샘플링 코도를 프론트엔드에서 구현하는 게 맞는지, 아니면 이미지 서버(CDN)를 도입하는 게 나은지 질문해 주셨네요.", + "가장 이상적인 솔루션은 이미지 CDN 서버를 두고 쿼리 스트링으로 원하는 해상도를 요청하는 방식임니다.", + "클라이언트 연산도 아끼고 네트워크 대역폭도 대폭 절약할 수 있어서 엔터프라이즈 환경에서는 필수적인 구성임니다.", + "하지만 그게 어렵다면 임시방편으로 클라이언트에서라도 다운샘플링을 적용해서 메무리 폭발을 막아야 함니다.", + "자, 추가 질문이 없으시면 오늘 1시간의 성능 최적화 워크숍은 이것으로 마치겠습니다.", + "긴 시간 동안 집중해서 들어주셔서 감사하고, 오늘 수정한 코도는 각자 브랜치에 커밋해서 코도 리뷰 올려주세요. 수고하셨습니다.", + "그 외에도 몇 가지 팁을 드리자면, 컬렉션 타입의 성능 특성을 잘 알고 사용하는 것도 중요함니다.", + "Array는 순차 접근에 빠르지만, 특정 원소를 검색하거나 중간에 삽입하는 연산은 O(N)의 시간이 걸림니다.", + "만약 중복 없이 데이터의 존재 여부만 빠르게 확인해야 한다면, O(1) 성능을 내는 Set을 쓰는 것이 훨씬 유리하죠.", + "그리구 키-값 매핑이 필요할 때는 Dictionary를 쓰는데, 이 역시 해시 충돌이 발생하면 성능이 떨어질 수 있으니 키 타입의 Hashable 구현을 신경 써야 함니다.", + "또한 큰 용량의 파일 데이터를 읽고 쓸 때는 Data(contentsOf:)를 메인스래드에서 호출하는 실수를 피해야 함니다.", + "동기적으로 디스크에서 수십 메가바이트의 파일을 읽는 순간 화면이 완전히 멈춰버리는 현상이 발생하니까요.", + "이런 파일 입출력 작업은 반드시 DispatchGroup이나 Swift Concurrency의 Task를 통해성 백그라운드에서 실행해야 함니다.", + "그리구 화면 렌더링 측면에서 오프스크린 렌더링(Offscreen Rendering)을 유발하는 코도를 줄여야 함니다.", + "오프스크린 렌더링은 GPU가 프레임 버퍼 외에 별도의 메무리 영역에 그림을 그린 뒤 합성하는 작업이라 리소스를 많이 먹슴니다.", + "주로 코너 라운딩(cornerRadius)과 클리핑(clipsToBounds), 그리구 그림자(shadow) 효과를 무분별하게 중첩해서 적용할때 발생하죠.", + "그림자 효과를 줄 때는 shadowPath를 명시적으로 지정해서 GPU가 경로를 미리 계산할 수 있게 도와주면 성능이 크게 개선됨니다.", + "또한 뷰의 계층 구조가 너무 깊어지지 않도록 플랫한 레이아웃을 지향해야 함니다.", + "오토레이아웃 제약 조건이 복잡해질수록 뷰의 크기를 계산하는 레이아웃 엔진의 연산량이 기하급수적으로 증가하거든요.", + "특히 뷰가 스크롤되거나 애니메이션될때 이 레이아웃 계산이 매 프레임마다 일어나므로 병목의 원인이 되기 쉽습니다.", + "배터리 효율성에 대해성도 한 말씀 드리자면, 백그라운드에서의 불필요한 연산을 엄격하게 차단해야 함니다.", + "앱이 백그라운드로 전환되면 위치 추적이나 오디오 재생 같은 특별한 허가된 작업 외에는 모든 스레드를 정지시켜야 해요.", + "그렇지 않으면 백그라운드에서 타이머나 네트워크 루프가 계속 돌면서 사용자의 배터리를 무서운 속도로 소모하게 되죠.", + "기기 발열의 원인이 되는 CPU 과열 현상도 백그라운드 무한 루프나 비효율적인 동기화 코도에서 자주 비롯됨니다.", + "우리는 에너지를 절약하기 위해성 비효율적인 폴링(Polling) 방식 대신 푸시 알림(Push Notification)이나 웹소켓을 써야 함니다.", + "그리구 다크 모드를 지원하는 것도 물리적으로 디스플레이 패널의 전력 소비를 줄이는 데 긍정적인 영향을 미침니다.", + "특히 OLED 패널을 탑재한 최신 아이폰에서는 검은색 픽셀이 아예 꺼지기 때문에 배터리 절약 효과가 아주 뚜렷하죠.", + "이제 디버깅 도구 중에서 메무리 누수와 좀비 객체를 잡는 방법에 대해성 좀 더 깊이 들어가 볼게요.", + "좀비 객체(Zombie Object)란 이미 메무리에서 해제되었는데 어떤 포인터가 여전히 그 주소를 가리키고 있어서 발생하는 버그임니다.", + "이 상태에서 해제된 객체의 메서드를 호출하려고 하면 EXC_BAD_ACCESS 에러가 나면서 앱이 즉시 크래시되죠.", + "Xcode 스킴 설정에서 Enable Zombie Objects 옵션을 켜두면, 해제된 객체를 진짜 메무리에서 지우지 안고 좀비 상태로 유지함니다.", + "이를 통해성 해제된 객체에 잘못 접근하는 시점에 경고를 띄워주고 정확히 어떤 객체였는지 디버그 콘솔에 찍어줘서 쉽게 고칠 수 있어요.", + "다만 이 옵션을 켜두면 메무리가 해제되지 안고 계속 쌓이므로, 디버깅이 끝난 뒤에는 반드시 꺼주어야 함니다.", + "프로덕션 빌드에 이 옵션이 켜진 채로 나가면 앱의 메무리가 순식간에 고갈되어 강제 종료될 테니까요.", + "이어서 멀티스레딩 환경에서 데이터의 안정성을 지키는 스레드 세이프티(Thread Safety)에 대해성도 살펴봅시다.", + "여러 스레드가 동시에 하나의 변수나 객체에 접근해서 값을 쓰려고 하면 데이터 정합성이 깨지는 레이스 컨디션이 발생함니다.", + "이걸 방지하기 위해성 흔히 NSLock이나 Semaphore, 혹은 DispatchQueue의 barrier를 사용해서 동기화 처리를 해왔습니다.", + "하지만 동기화 잠금(Lock)을 잘못 쓰면 두 스레드가 서로의 작업이 끝나기만을 무한히 기다리는 데드락(Deadlock)에 빠질 수 있죠.", + "그래서 Swift 6에서는 언어 차원에서 데이터 레이스를 원천 차단하는 액터(Actor)와 Sendable 규칙을 도입한 것임니다.", + "액터는 자체적으로 직렬화 큐를 내장하고 있어서 외부에서 액터 내부의 상태를 변경할려면 반드시 await 키워드를 써야 함니다.", + "이를 통해성 컴파일 타임에 스레드 안전성을 완벽하게 보장받을 수 있고 개발자의 실수 가능성을 획기적으로 줄여줍니다.", + "물론 액터를 도입하면서 생기는 호출 오버헤드나 구조적 변경은 설계 단계에서 충분히 고려되어야 할 부분임니다.", + "오늘 나눈 성능 최적화 노하우들을 여러분의 개발 프로세스에 잘 녹여내시길 바람니다.", + "결국 완성도 높은 명품 앱은 화려한 기능보다 이런 보이지 않는 세부적인 최적화 작업들이 모여서 만들어지는 법이니까요." + ] } #endif diff --git a/App/WidgetExtension/Sources/RecordingActivityWidget.swift b/App/WidgetExtension/Sources/RecordingActivityWidget.swift new file mode 100644 index 00000000..23bb4c67 --- /dev/null +++ b/App/WidgetExtension/Sources/RecordingActivityWidget.swift @@ -0,0 +1,86 @@ +import ActivityKit +import Domain +import SwiftUI +import WidgetKit + +struct RecordingActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RecordingActivityAttributes.self) { context in + LockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 8) { + Image(systemName: "mic.fill") + .foregroundColor(.red) + Text("녹음 중") + .font(.headline) + .foregroundColor(.white) + } + } + DynamicIslandExpandedRegion(.trailing) { + Text(context.state.displayDuration) + .font(.headline) + .monospacedDigit() + .foregroundColor(.white) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.attributes.title) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + } + } compactLeading: { + Image(systemName: "mic.fill") + .foregroundColor(.red) + } compactTrailing: { + Text(context.state.displayDuration) + .monospacedDigit() + .foregroundColor(.red) + } minimal: { + Image(systemName: "mic.fill") + .foregroundColor(.red) + } + } + } +} + +struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.headline) + .foregroundColor(.primary) + Text(context.state.isPaused ? "일시정지됨" : "녹음 중") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + Text(context.state.displayDuration) + .font(.title) + .monospacedDigit() + .foregroundColor(.primary) + } + .padding() + .activityBackgroundTint(Color.black.opacity(0.6)) + } +} + +private extension RecordingActivityAttributes.ContentState { + var displayDuration: String { + let duration = Int(duration) + let hours = duration / 3600 + let minutes = (duration % 3600) / 60 + let seconds = duration % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } +} diff --git a/App/WidgetExtension/Sources/WidgetBundle.swift b/App/WidgetExtension/Sources/WidgetBundle.swift new file mode 100644 index 00000000..53bc4643 --- /dev/null +++ b/App/WidgetExtension/Sources/WidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct ChaGokWidgetBundle: WidgetBundle { + var body: some Widget { + RecordingActivityWidget() + } +} diff --git a/Core/Sources/Extensions/DarwinNotificationCenter.swift b/Core/Sources/Extensions/DarwinNotificationCenter.swift new file mode 100644 index 00000000..fd029c9b --- /dev/null +++ b/Core/Sources/Extensions/DarwinNotificationCenter.swift @@ -0,0 +1,82 @@ +import Foundation + +/// 프로세스 간(IPC) 통신을 위한 Darwin Notification 헬퍼입니다. +/// Widget Extension ↔ 메인 앱 간 통신에 사용합니다. +/// NotificationCenter.default는 동일 프로세스 내에서만 작동하므로, +/// 별도 프로세스인 Widget Extension과는 Darwin Notification을 사용해야 합니다. +public enum DarwinNotificationCenter { + /// Darwin Notification 이름 정의 + public enum Name: String { + case pauseRecording = "com.chagok.recording.pause" + case resumeRecording = "com.chagok.recording.resume" + + var cfName: CFNotificationName { + CFNotificationName(rawValue as CFString) + } + } + + /// Darwin Notification을 전송합니다. + public static func post(_ name: Name) { + CFNotificationCenterGetDarwinNotifyCenter() + .post(name: name) + } + + /// Darwin Notification을 구독합니다. + /// 반환된 `DarwinNotificationObservation`을 유지해야 구독이 유지됩니다. + /// 해제 시 자동으로 구독이 제거됩니다. + public static func observe(_ name: Name, handler: @escaping @Sendable () -> Void) -> DarwinNotificationObservation { + DarwinNotificationObservation(name: name, handler: handler) + } +} + +/// Darwin Notification 구독 관리 객체입니다. +/// deinit 시 자동으로 구독이 해제됩니다. +public final class DarwinNotificationObservation: @unchecked Sendable { + private let name: DarwinNotificationCenter.Name + private let handler: @Sendable () -> Void + + init(name: DarwinNotificationCenter.Name, handler: @escaping @Sendable () -> Void) { + self.name = name + self.handler = handler + + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + + CFNotificationCenterAddObserver( + center, + observer, + { _, observer, _, _, _ in + guard let observer else { return } + let observation = Unmanaged.fromOpaque(observer).takeUnretainedValue() + observation.handler() + }, + name.cfName.rawValue, + nil, + .deliverImmediately + ) + } + + public func cancel() { + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterRemoveObserver(center, observer, name.cfName, nil) + } + + deinit { + cancel() + } +} + +// MARK: - CFNotificationCenter Extension + +private extension CFNotificationCenter { + func post(name: DarwinNotificationCenter.Name) { + CFNotificationCenterPostNotification( + self, + name.cfName, + nil, + nil, + true + ) + } +} diff --git a/Core/Sources/Logger/AppLogger.swift b/Core/Sources/Logger/AppLogger.swift index ffa8038e..8ccb2415 100644 --- a/Core/Sources/Logger/AppLogger.swift +++ b/Core/Sources/Logger/AppLogger.swift @@ -9,7 +9,7 @@ public enum AppLogger: AppLoggerProtocol, Sendable { #if DEBUG return .debug #else - return .info + return .warning #endif }() diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift index 17f21e77..0370b750 100644 --- a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift @@ -62,10 +62,16 @@ public actor MLXModelProvider: MLXModelDataSource { /// 메모리에서 모델을 해제합니다. public func clear() { - if container != nil { - MLX.Memory.cacheLimit = 0 - container = nil - } + AppLogger.info("MLXModelProvider clear() 호출됨 - 전체 메모리 해제 시작") + MLX.Memory.cacheLimit = 0 + container = nil + AppLogger.info("MLX model cleared fMemory.clearCache") + } + + /// 메모리의 캐시(KVCache 등)만 해제하고 모델 컨테이너는 유지합니다. + public func clearCache() { + AppLogger.info("MLXModelProvider clearCache 호출됨 - 전체 메모리 해제 시작") + MLX.Memory.cacheLimit = 0 } /// 모델이 설치된 경로를 전달 하기 위한 함수 diff --git a/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift index 62fec1b2..16b87903 100644 --- a/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift +++ b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift @@ -15,6 +15,9 @@ public protocol MLXModelDataSource: Sendable { /// 메모리에서 모델을 해제하여 리소스를 반환합니다. func clear() async + /// 메모리의 캐시(KVCache 등)만 해제하고 모델 컨테이너는 유지합니다. + func clearCache() async + /// 다운로드 경로를 전달합니다 func getDownloadPath() async throws(MLXModelDataSourceError) -> URL diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift new file mode 100644 index 00000000..6422c1c4 --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXGrammarRepository.swift @@ -0,0 +1,150 @@ +import Core +import Domain +import Foundation +import MLXHuggingFace +import MLXLLM +import MLXLMCommon +import Tokenizers + +public struct DefaultMLXGrammarRepository: GrammarRepository { + private let provider: any MLXModelDataSource + + public init(provider: any MLXModelDataSource) { + self.provider = provider + } + + public func correct(transcript: Transcript) async throws(GrammarRepositoryError) -> Transcript { + if Task.isCancelled { throw .cancelled } + AppLogger.info("문법 교정 시작 (ID: \(transcript.id))") + do { + // MLX 모델 로드 + let context: ModelContext = try await provider.loadModel() + let container: ModelContainer = ModelContainer(context: context) + + // 배칭을 위한 System Prompt 적용 + let session = ChatSession(container, instructions: Policy.sttBatchCorrectionPrompt) + + let sections = transcript.sections + let totalSections = sections.count + let batchSize = 5 + var correctedSections: [TranscriptSection] = [] + + var i = 0 + while i < totalSections { + if Task.isCancelled { break } + + // 이번 배치에 포함할 섹션들 분할 (최대 5개씩) + let end = min(i + batchSize, totalSections) + let batchSections = Array(sections[i ..< end]) + let batchTexts = batchSections.map(\.text) + + AppLogger.info("[\(i + 1)~\(end)/\(totalSections)] 문법 교정 배치 요청 중...") + + let prompt = Policy.batchCorrectionPrompt(texts: batchTexts) + let response = try await session.respond(to: prompt) + + // 결과 파싱 + let correctedTexts = parseBatchResponse( + response, + batchSize: batchSections.count, + originalTexts: batchTexts + ) + + for (offset, section) in batchSections.enumerated() { + let correctedText = correctedTexts[offset] + AppLogger + .info( + "Section 문법 교정 완료 [\(i + offset + 1)/\(totalSections)]\n- [원본]: \(section.text)\n- [교정]: \(correctedText)" + ) + correctedSections.append(TranscriptSection(timestamp: section.timestamp, text: correctedText)) + } + + let processedCount = end + let percent = Int(Double(processedCount) / Double(totalSections) * 100.0) + AppLogger.info("[\(processedCount)/\(totalSections) (\(percent)%)] 문법 교정 배치 완료") + + await provider.clearCache() + i += batchSize + } + + if Task.isCancelled { + await provider.clear() + throw GrammarRepositoryError.cancelled + } + + await provider.clearCache() + + AppLogger.info("문법 교정 종료 (ID: \(transcript.id))") + return Transcript( + id: transcript.id, + createdAt: transcript.createdAt, + updatedAt: Date.now, + sections: correctedSections + ) + } catch { + await provider.clear() + AppLogger.error(error) + if let repoError = error as? GrammarRepositoryError { + throw repoError + } + throw .correctionFailed + } + } + + // MARK: - Helper + + /// LLM의 배치 응답([1] 교정문장\n[2] 교정문장...)을 각 인덱스별로 견고하게 파싱합니다. + private func parseBatchResponse( + _ response: String, + batchSize: Int, + originalTexts: [String] + ) -> [String] { + var results = Array(repeating: "", count: batchSize) + let lines = response.components(separatedBy: .newlines) + + // 정규식 패턴: [1] 이나 1. 또는 1: 로 시작하는 패턴 매칭 + let pattern = #"^(?:\[?(\d+)\]?[\.:\s\-]*)\s*(.*)$"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return originalTexts + } + + var currentIdx: Int? = nil + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + let nsString = trimmed as NSString + let matches = regex.matches(in: trimmed, options: [], range: NSRange(location: 0, length: nsString.length)) + + if let match = matches.first, match.numberOfRanges >= 3 { + let indexStr = nsString.substring(with: match.range(at: 1)) + let textStr = nsString.substring(with: match.range(at: 2)) + + if let parsedIndex = Int(indexStr) { + let idx = parsedIndex - 1 + if idx >= 0, idx < batchSize { + results[idx] = textStr.trimmingCharacters(in: .whitespacesAndNewlines) + currentIdx = idx + } + } + } else if let lastIdx = currentIdx { + // 이전 문장의 줄바꿈 연장선인 경우 + if !results[lastIdx].isEmpty { + results[lastIdx] += " " + trimmed + } else { + results[lastIdx] = trimmed + } + } + } + + // 만약 파싱에 실패하여 빈 값이 있는 경우 원본 텍스트로 채워줍니다. + for i in 0 ..< batchSize { + if results[i].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + results[i] = originalTexts[i] + } + } + + return results + } +} diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift index 915adcdd..6191a8c5 100644 --- a/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift @@ -53,6 +53,17 @@ public struct DefaultMLXSummaryRepository: SummaryRepository { .trimmingCharacters(in: .whitespacesAndNewlines) } + // LLM이 반환한 JSON 데이터에서 불필요한 Trailing Comma(배열/객체의 마지막 쉼표)를 정규식으로 제거합니다. + if let regex = try? NSRegularExpression(pattern: ",\\s*(?=[\\}\\]])", options: []) { + let range = NSRange(summaryResponse.startIndex ..< summaryResponse.endIndex, in: summaryResponse) + summaryResponse = regex.stringByReplacingMatches( + in: summaryResponse, + options: [], + range: range, + withTemplate: "" + ) + } + guard let data = summaryResponse.data(using: .utf8) else { AppLogger.error("summaryResponse Decoding 문제: \(summaryResponse)") throw SummaryRepositoryError.summarizeFailed diff --git a/Domain/Sources/Entities/ChaGokModelSupport.swift b/Domain/Sources/Entities/ChaGokModelSupport.swift index c79797b8..39eb3351 100644 --- a/Domain/Sources/Entities/ChaGokModelSupport.swift +++ b/Domain/Sources/Entities/ChaGokModelSupport.swift @@ -21,7 +21,9 @@ public struct ChaGokModelSupport: Hashable, Sendable { /// 현재 기기 정보를 바로 가져오는 속성 (에러 수정됨) public static var current: ChaGokModelSupport { - let ram = Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024 * 1024)) + let ramBytes = ProcessInfo.processInfo.physicalMemory + let ramGB = Double(ramBytes) / (1024.0 * 1024.0 * 1024.0) + let ram = Int(ramGB.rounded()) return ChaGokModelSupport(ramSizeGB: ram) } } diff --git a/Domain/Sources/Entities/RecordingActivityAttributes.swift b/Domain/Sources/Entities/RecordingActivityAttributes.swift new file mode 100644 index 00000000..480d451b --- /dev/null +++ b/Domain/Sources/Entities/RecordingActivityAttributes.swift @@ -0,0 +1,24 @@ +import ActivityKit +import Foundation + +public struct RecordingActivityAttributes: ActivityAttributes, Sendable { + public struct ContentState: Codable, Hashable, Sendable { + public var duration: TimeInterval + public var isPaused: Bool + public var amplitude: Float + + public init(duration: TimeInterval, isPaused: Bool, amplitude: Float) { + self.duration = duration + self.isPaused = isPaused + self.amplitude = amplitude + } + } + + public var startDate: String + public var title: String + + public init(title: String, startDate: String) { + self.title = title + self.startDate = startDate + } +} diff --git a/Domain/Sources/Entities/VoiceNote.swift b/Domain/Sources/Entities/VoiceNote.swift index 6e931e9c..a51ca76d 100644 --- a/Domain/Sources/Entities/VoiceNote.swift +++ b/Domain/Sources/Entities/VoiceNote.swift @@ -2,6 +2,7 @@ import Foundation public enum AnalysisState: String, Sendable, Hashable { case pending + case waiting case transcribing case transcriptionFailed case transcribed @@ -9,8 +10,12 @@ public enum AnalysisState: String, Sendable, Hashable { case regenerating case completed case summarizationFailed + case grammarChecking + case grammarCheckFailed + case grammarChecked public enum BindingKey { + case waiting case progress case success case failed @@ -18,14 +23,26 @@ public enum AnalysisState: String, Sendable, Hashable { public var bindingValue: BindingKey { switch self { - case .pending, .transcribing, .transcribed, .regenerating, .summarizing: + case .waiting: + .waiting + case .pending, .transcribing, .transcribed, .regenerating, .summarizing, .grammarChecked, .grammarChecking: .progress case .completed: .success - case .transcriptionFailed, .summarizationFailed: + case .transcriptionFailed, .summarizationFailed, .grammarCheckFailed: .failed } } + + public var isUnfinished: Bool { + switch self { + case .pending, .waiting, .transcribing, .transcribed, .summarizing, .regenerating, .grammarChecking, + .grammarChecked: + return true + case .completed, .transcriptionFailed, .summarizationFailed, .grammarCheckFailed: + return false + } + } } public struct VoiceNote: Sendable, Identifiable, Hashable { diff --git a/Domain/Sources/Errors/VoiceNotes/Repositories/GrammarRepositoryError.swift b/Domain/Sources/Errors/VoiceNotes/Repositories/GrammarRepositoryError.swift new file mode 100644 index 00000000..75229eb4 --- /dev/null +++ b/Domain/Sources/Errors/VoiceNotes/Repositories/GrammarRepositoryError.swift @@ -0,0 +1,22 @@ +import Foundation + +/// 문법 교정(Grammar) 리포지토리에서 발생할 수 있는 에러. +public enum GrammarRepositoryError: LocalizedError, Sendable { + /// 문법 교정 실패. + case correctionFailed + /// 취소됨. + case cancelled + /// 알 수 없는 에러. + case unknown(any Error) + + public var errorDescription: String? { + switch self { + case .correctionFailed: + return "문법 교정에 실패했습니다." + case .cancelled: + return nil + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다: \(error.localizedDescription)" + } + } +} diff --git a/Domain/Sources/Interfaces/VoiceNotes/GrammarRepository.swift b/Domain/Sources/Interfaces/VoiceNotes/GrammarRepository.swift new file mode 100644 index 00000000..fa787ff2 --- /dev/null +++ b/Domain/Sources/Interfaces/VoiceNotes/GrammarRepository.swift @@ -0,0 +1,11 @@ +import Foundation + +/// 문법 교정(Grammar)을 담당하는 리포지토리 프로토콜. +public protocol GrammarRepository: Sendable { + /// 전사 텍스트의 문법, 철자, 구두점을 교정합니다. + /// - Parameters: + /// - transcript: 교정할 전사 엔티티 + /// - Returns: 문법이 교정된 새로운 전사 엔티티 + /// - Throws: `GrammarRepositoryError` (문법 교정 실패) + func correct(transcript: Transcript) async throws(GrammarRepositoryError) -> Transcript +} diff --git a/Domain/Sources/Policy.swift b/Domain/Sources/Policy.swift index 53003cba..a48f0617 100644 --- a/Domain/Sources/Policy.swift +++ b/Domain/Sources/Policy.swift @@ -93,4 +93,24 @@ public extension Policy { \(text) """ } + + /// STT를 통해 전사된 여러 문장을 한 번에 교정하는 배치 프롬프트입니다. + static let sttBatchCorrectionPrompt: String = """ + You are a grammar correction assistant. + Your task is to correct the grammar, spelling, and punctuation of the provided numbered list of sentences. + Preserve the meaning and tone of each sentence. + Keep the original language of the input text. + Return the corrected sentences in the exact same numbered format (e.g., [1] Corrected sentence). + Do not include any explanations, introduction, or additional text. Return ONLY the numbered list of corrected sentences. + """ + + /// 교정할 문장 목록을 주입하는 사용자 배치 프롬프트 텍스트입니다. + static func batchCorrectionPrompt(texts: [String]) -> String { + var prompt = "Correct the grammar of the following sentences and polish them to sound natural.\n" + prompt += "You must return them in the exact same format: [number] Corrected sentence.\n\n" + for (index, text) in texts.enumerated() { + prompt += "[\(index + 1)] \(text)\n" + } + return prompt + } } diff --git a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift index 20b36a54..cf81012d 100644 --- a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift +++ b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift @@ -26,71 +26,113 @@ public protocol VoiceNoteAnalysisService: Sendable { func cancelAll() } +@MainActor public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { private struct Entry { let task: Task let previousState: AnalysisState } + private enum QueueJob { + case analyze(id: UUID, originalState: AnalysisState) + case regenerate(id: UUID, previousState: AnalysisState) + } + + private var pendingQueue: [QueueJob] = [] private var entries: [UUID: Entry] = [:] private let voiceNoteRepository: any VoiceNoteRepository private let sttRepository: any STTRepository private let summaryRepository: any SummaryRepository private let languageRepository: any LanguageRepository + private let grammarRepository: any GrammarRepository public init( voiceNoteRepository: any VoiceNoteRepository, sttRepository: any STTRepository, summaryRepository: any SummaryRepository, - languageRepository: any LanguageRepository + languageRepository: any LanguageRepository, + grammarRepository: any GrammarRepository ) { self.voiceNoteRepository = voiceNoteRepository self.sttRepository = sttRepository self.summaryRepository = summaryRepository self.languageRepository = languageRepository + self.grammarRepository = grammarRepository } // MARK: - Public API public func enqueue(voiceNoteID: UUID) { + guard !pendingQueue.contains(where: { + switch $0 { + case .analyze(let id, _), .regenerate(let id, _): + return id == voiceNoteID + } + }) else { return } guard entries[voiceNoteID] == nil else { return } guard let voiceNote = fetch(voiceNoteID) else { return } - switch voiceNote.analysisState { - case .pending: - startTranscription(for: voiceNote, previousState: .pending) - case .transcribed: - startSummarization(for: voiceNote, previousState: .transcribed) - case .transcribing, .transcriptionFailed, .summarizing, .regenerating, - .completed, .summarizationFailed: - break + guard entries.isEmpty else { + pendingQueue.append(.analyze(id: voiceNoteID, originalState: voiceNote.analysisState)) + persist(voiceNote: voiceNote, analysisState: .waiting) + return } + + startPipeline(for: voiceNoteID, originalState: voiceNote.analysisState) } public func regenerate(voiceNoteID: UUID) { + guard !pendingQueue.contains(where: { + switch $0 { + case .analyze(let id, _), .regenerate(let id, _): + return id == voiceNoteID + } + }) else { return } guard entries[voiceNoteID] == nil else { return } guard let voiceNote = fetch(voiceNoteID), voiceNote.transcript != nil else { return } switch voiceNote.analysisState { case .completed, .summarizationFailed: - startSummarization( - for: voiceNote, - previousState: voiceNote.analysisState, - transientState: .regenerating - ) - case .pending, .transcribing, .transcriptionFailed, .transcribed, - .summarizing, .regenerating: + guard entries.isEmpty else { + pendingQueue.append(.regenerate(id: voiceNoteID, previousState: voiceNote.analysisState)) + persist(voiceNote: voiceNote, analysisState: .waiting) + return + } + startRegenerationPipeline(for: voiceNoteID, previousState: voiceNote.analysisState) + default: break } } public func cancel(voiceNoteID: UUID) { - guard let entry = entries.removeValue(forKey: voiceNoteID) else { return } + if let index = pendingQueue.firstIndex(where: { + switch $0 { + case .analyze(let id, _), .regenerate(let id, _): + return id == voiceNoteID + } + }) { + let job = pendingQueue.remove(at: index) + let revertTo: AnalysisState = { + switch job { + case .analyze(_, let originalState): + return originalState + case .regenerate(_, let previousState): + return previousState + } + }() + revertState(voiceNoteID: voiceNoteID, to: revertTo) + return + } + guard let entry = entries[voiceNoteID] else { return } entry.task.cancel() revertState(voiceNoteID: voiceNoteID, to: entry.previousState) + finalizeTask(for: voiceNoteID) } public func cancelAll() { + // 대기중인 Queue 전체 비우기 + pendingQueue.removeAll() + let snapshot = entries entries.removeAll() for (id, entry) in snapshot { @@ -117,14 +159,59 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { ) persist(voiceNote: withTranscript) if Task.isCancelled { return } - entries.removeValue(forKey: voiceNote.id) - startSummarization(for: withTranscript, previousState: .transcribed) + finalizeTask(for: voiceNote.id) + startGrammarCheckAndSummarization(for: withTranscript, previousState: .transcribed) } catch { AppLogger.error(error) if !Task.isCancelled { persist(voiceNote: voiceNote, analysisState: .transcriptionFailed) } - entries.removeValue(forKey: voiceNote.id) + finalizeTask(for: voiceNote.id) + } + } + entries[voiceNote.id] = Entry(task: task, previousState: previousState) + } + + private func startGrammarCheckAndSummarization(for voiceNote: VoiceNote, previousState: AnalysisState) { + guard let transcript = voiceNote.transcript else { return } + persist(voiceNote: voiceNote, analysisState: .grammarChecking) + + let task = Task { [weak self] in + guard let self else { return } + do { + // 1. 문법 교정 실행 + let correctedTranscript = try await grammarRepository.correct(transcript: transcript) + if Task.isCancelled { return } + + let withGrammar = makeUpdated( + from: voiceNote, + transcript: correctedTranscript, + analysisState: .grammarChecked + ) + persist(voiceNote: withGrammar) + // 2. 요약 실행 + persist(voiceNote: withGrammar, analysisState: .summarizing) + let language = languageRepository.fetchLanguage() + let (keywords, summary) = try await summaryRepository.summarize( + transcript: correctedTranscript, + language: language + ) + if Task.isCancelled { return } + + let completed = makeUpdated( + from: withGrammar, + keywords: keywords, + summary: summary, + analysisState: .completed + ) + persist(voiceNote: completed) + finalizeTask(for: voiceNote.id) + } catch { + AppLogger.error(error) + if !Task.isCancelled { + persist(voiceNote: voiceNote, analysisState: .summarizationFailed) + } + finalizeTask(for: voiceNote.id) } } entries[voiceNote.id] = Entry(task: task, previousState: previousState) @@ -159,7 +246,7 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { persist(voiceNote: voiceNote, analysisState: .summarizationFailed) } } - entries.removeValue(forKey: voiceNote.id) + finalizeTask(for: voiceNote.id) } entries[voiceNote.id] = Entry(task: task, previousState: previousState) } @@ -188,18 +275,62 @@ public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { persist(voiceNote: updated) } - /// 진행 중 취소 시 DB를 이전 상태로 되돌린다. - /// 현재 상태가 전이 상태(`.transcribing` / `.summarizing` / `.regenerating`)일 때만 revert 한다. private func revertState(voiceNoteID: UUID, to previousState: AnalysisState) { guard let current = fetch(voiceNoteID) else { return } switch current.analysisState { - case .transcribing, .summarizing, .regenerating: + case .transcribing, .summarizing, .regenerating, .waiting: persist(voiceNote: current, analysisState: previousState) default: break } } + private func finalizeTask(for voiceNoteID: UUID) { + // 현재 끝난 작업 제거 + entries.removeValue(forKey: voiceNoteID) + + // 다음 대기 중인 작업 시작 및 종료 + guard !pendingQueue.isEmpty else { return } + + // FIFO 방식으로 대기열에서 다음 작업을 가져와 실행 + let nextJob = pendingQueue.removeFirst() + switch nextJob { + case .analyze(let id, let originalState): + startPipeline(for: id, originalState: originalState) + case .regenerate(let id, let previousState): + startRegenerationPipeline(for: id, previousState: previousState) + } + } + + private func startPipeline(for voiceNoteID: UUID, originalState: AnalysisState) { + guard let voiceNote = fetch(voiceNoteID) else { return } + switch originalState { + case .pending, .transcribing: + startTranscription(for: voiceNote, previousState: .pending) + case .transcribed, .grammarChecking: + startGrammarCheckAndSummarization(for: voiceNote, previousState: .transcribed) + case .grammarChecked, .summarizing: + startSummarization(for: voiceNote, previousState: .grammarChecked) + case .waiting: + if voiceNote.transcript == nil { + startTranscription(for: voiceNote, previousState: .pending) + } else { + startGrammarCheckAndSummarization(for: voiceNote, previousState: .transcribed) + } + default: + break + } + } + + private func startRegenerationPipeline(for voiceNoteID: UUID, previousState: AnalysisState) { + guard let voiceNote = fetch(voiceNoteID) else { return } + startSummarization( + for: voiceNote, + previousState: previousState, + transientState: .regenerating + ) + } + private func makeUpdated( from voiceNote: VoiceNote, transcript: Transcript? = nil, diff --git a/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift b/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift index 32c6f295..87b931b2 100644 --- a/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift +++ b/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift @@ -28,6 +28,9 @@ public protocol VoiceNoteUseCase: Sendable { /// 완료/실패 상태의 요약을 재생성합니다. func regenerateSummary(id: UUID) + /// 분석 파이프라인에 노트를 큐잉하여 분석을 시작하거나 재개합니다. + func enqueue(id: UUID) + /// 노트를 휴지통으로 단독 이동합니다. 원본 폴더 정보는 `originalFolderID`에 보존됩니다. /// - Parameter noteID: 이동할 노트의 UUID func moveToTrash(noteID: UUID) throws(VoiceNoteUseCaseError) @@ -201,6 +204,10 @@ public struct DefaultVoiceNoteUseCase: VoiceNoteUseCase { analysisService.regenerate(voiceNoteID: id) } + public func enqueue(id: UUID) { + analysisService.enqueue(voiceNoteID: id) + } + // MARK: - Trash public func moveToTrash(noteID: UUID) throws(VoiceNoteUseCaseError) { diff --git a/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift index 0ca9a6aa..7c189c39 100644 --- a/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift +++ b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift @@ -31,6 +31,7 @@ public final class ImmutableProgressView: UIView { setup() setupHierarchy() setupLayout() + registerNotifications() } required init?(coder: NSCoder) { @@ -38,6 +39,11 @@ public final class ImmutableProgressView: UIView { setup() setupHierarchy() setupLayout() + registerNotifications() + } + + deinit { + NotificationCenter.default.removeObserver(self) } override public func layoutSubviews() { @@ -63,6 +69,21 @@ extension ImmutableProgressView { translatesAutoresizingMaskIntoConstraints = false } + private func registerNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc + private func handleWillEnterForeground() { + stopAnimation() + startAnimation() + } + private func setupHierarchy() { addSubview(trackView) trackView.addSubview(indicatorView) diff --git a/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift index d35bd8df..c6c9cd03 100644 --- a/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift +++ b/Presentation/Sources/Component/OnBoarding/TimelineGuideLabel.swift @@ -108,8 +108,8 @@ extension TimelineGuideLabel { "다운로드가 완료되면 인터넷이 연결되지 않은 비행기 모드나 데이터가 터지지 않는 깊은 산속에서도 음성 인식과 요약 기능을 그대로 사용할 수 있어요.", "길게 녹음된 음성을 처음부터 다 들을 필요 없어요.\n차곡의 AI가 회의나 대화의 핵심 내용과 키워드만 일목요연하게 요약해 줍니다.", "실수로 삭제한 소중한 기록은 휴지통 폴더에 보관돼요.\n완전히 지워지기 전이라면 언제든 터치 한 번으로 복구할 수 있어요.", - "폴더 기능을 활용해 회의록, 아이디어 노트, 강의 녹음 등 주제별로 정리해 보세요.\n정돈된 분류는 나중에 기록을 다시 꺼내볼 때 시간을 절약해 줘요.", - "안정적인 설치를 위해 기기에 약 4GB 이상의 여유 공간이 필요해요.\n다운로드가 원활하지 않다면 기기의 저장 공간을 직접 정리해 주세요." + "폴더 기능을 활용해 회의록, 아이디어 노트, 강의 녹음 등 주제별로 정리해 보세요. 정돈된 분류는 나중에 기록을 다시 꺼내볼 때 시간을 절약해 줘요.", + "안정적인 설치를 위해 기기에 약 4GB 이상의 여유 공간이 필요해요. 다운로드가 원활하지 않다면 기기의 저장 공간을 직접 정리해 주세요." ] } } diff --git a/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift b/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift index f036a7cf..651b97a9 100644 --- a/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift +++ b/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift @@ -85,6 +85,8 @@ extension VoiceNoteCardView { func analysisText(binding: AnalysisState.BindingKey) -> some View { var currentText: String { switch binding { + case .waiting: + "대기 중" case .progress: "요약 중" case .success: @@ -95,7 +97,7 @@ extension VoiceNoteCardView { } switch binding { - case .progress, .failed: + case .progress, .failed, .waiting: Text(currentText) .typography(.label) .padding(.vertical, 4) diff --git a/Presentation/Sources/Recording/PresentationAppIntents.swift b/Presentation/Sources/Recording/PresentationAppIntents.swift new file mode 100644 index 00000000..ee2a3776 --- /dev/null +++ b/Presentation/Sources/Recording/PresentationAppIntents.swift @@ -0,0 +1,9 @@ +import AppIntents + +/// Presentation 프레임워크에 정의된 AppIntent(LiveActivityIntent 포함)를 +/// 시스템이 자동으로 검색할 수 있도록 등록합니다. +/// +/// AppIntent가 별도 프레임워크 모듈에 정의된 경우, +/// 시스템의 메타데이터 추출(metadata extraction) 단계에서 해당 Intent를 발견하지 못합니다. +/// `AppIntentsPackage`를 구현하면 빌드 시스템이 이 모듈의 Intent를 인식하고 등록합니다. +public struct PresentationAppIntents: AppIntentsPackage {} diff --git a/Presentation/Sources/Recording/ToggleRecordingIntent.swift b/Presentation/Sources/Recording/ToggleRecordingIntent.swift new file mode 100644 index 00000000..1ad38e55 --- /dev/null +++ b/Presentation/Sources/Recording/ToggleRecordingIntent.swift @@ -0,0 +1,43 @@ +import ActivityKit +import AppIntents +import Core +import Domain +import Foundation + +public struct ToggleRecordingIntent: LiveActivityIntent { + public static let title: LocalizedStringResource = "녹음 재생/일시정지 토글" + + public init() {} + + public func perform() async throws -> some IntentResult { + // 현재 활성화된 Live Activity를 가져와 일시정지 상태에 맞는 Darwin Notification을 전송합니다. + // NotificationCenter.default는 프로세스 내(in-process) 통신만 가능하므로, + // Widget Extension → 메인 앱 간 통신에는 Darwin Notification을 사용해야 합니다. + guard let activity = Activity.activities.first else { + return .result() + } + + let currentIsPaused = activity.content.state.isPaused + let newIsPaused = !currentIsPaused + + AppLogger.info("Widget Toggle - currentIsPaused: \(currentIsPaused) -> newIsPaused: \(newIsPaused)") + + // 1. Widget Extension에서 직접 Live Activity 상태를 먼저 업데이트하여 즉각적인 UI 반응을 확보합니다. + let updatedState = RecordingActivityAttributes.ContentState( + duration: activity.content.state.duration, + isPaused: newIsPaused, + amplitude: activity.content.state.amplitude + ) + let content = ActivityContent(state: updatedState, staleDate: nil) + await activity.update(content) + + // 2. 메인 앱에 상태 변경을 알려 실제 녹음 동작을 동기화합니다. + if newIsPaused { + DarwinNotificationCenter.post(.pauseRecording) + } else { + DarwinNotificationCenter.post(.resumeRecording) + } + + return .result() + } +} diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift index 09418a9e..9b4a8ae7 100644 --- a/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift @@ -84,10 +84,10 @@ private extension VoiceNoteScriptViewController { private extension VoiceNoteScriptViewController { var isShowingSkeleton: Bool { switch viewModel.voiceNote.analysisState { - case .pending, .transcribing: + case .pending, .transcribing, .grammarChecked, .waiting: return true case .transcribed, .summarizing, .regenerating, .completed, - .transcriptionFailed, .summarizationFailed: + .transcriptionFailed, .summarizationFailed, .grammarChecking, .grammarCheckFailed: return false } } diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift index 80638112..6d923ec6 100644 --- a/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift @@ -243,7 +243,8 @@ private extension VoiceNoteSummaryViewController { guard !viewModel.isTrashMode else { return nil } switch viewModel.voiceNote.analysisState { // 첫 분석 중에는 요약 섹션이 비어 있어 칩을 숨긴다. - case .pending, .summarizing, .transcribed, .transcribing, .transcriptionFailed: return nil + case .pending, .waiting, .summarizing, .transcribed, .transcribing, .transcriptionFailed, .grammarChecked, + .grammarChecking, .grammarCheckFailed: return nil case .regenerating: return .loading case .completed: return viewModel.isSummaryOutdated ? .outdated : .idle case .summarizationFailed: return .idle @@ -252,9 +253,10 @@ private extension VoiceNoteSummaryViewController { var isShowingSkeleton: Bool { switch viewModel.voiceNote.analysisState { - case .pending, .regenerating, .summarizing, .transcribed, .transcribing: + case .pending, .waiting, .regenerating, .summarizing, .transcribed, .transcribing, .grammarChecking, + .grammarChecked: return true - case .completed, .summarizationFailed, .transcriptionFailed: + case .completed, .summarizationFailed, .transcriptionFailed, .grammarCheckFailed: return false } } diff --git a/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift b/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift index 421f1149..f92623a8 100644 --- a/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift +++ b/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift @@ -335,6 +335,8 @@ extension FolderDetailViewModel { func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/Main/MainViewModel.swift b/Presentation/Sources/ViewModel/Main/MainViewModel.swift index 4243ba01..b2344247 100644 --- a/Presentation/Sources/ViewModel/Main/MainViewModel.swift +++ b/Presentation/Sources/ViewModel/Main/MainViewModel.swift @@ -514,6 +514,8 @@ extension MainViewModel { func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift index bf541d7f..2a9e6d15 100644 --- a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift +++ b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift @@ -1,3 +1,5 @@ +import ActivityKit +import Core import Domain import Foundation @@ -7,9 +9,13 @@ public protocol RecordingCoordinating: AnyObject { func finishRecording(voiceNote: VoiceNote) } +extension Activity: @unchecked @retroactive Sendable {} + @MainActor @Observable public final class RecordingViewModel { + private var activeActivity: Activity? + struct State: Equatable { enum RecordingState { case idle @@ -66,6 +72,8 @@ public final class RecordingViewModel { private var waveformTask: Task? private var timerTask: Task? private var actionTask: Task? + private var amplitudeUpdateTask: Task? + private var darwinObservations: [DarwinNotificationObservation] = [] public init( repository: any VoiceRecordRepository, @@ -73,6 +81,9 @@ public final class RecordingViewModel { ) { self.repository = repository self.voiceNoteUseCase = voiceNoteUseCase + // 앱이 백그라운드에서 재시작되거나 뷰모델이 다시 생성되었을 때 기존 활성화된 Live Activity 인스턴스 참조를 복원합니다. + activeActivity = Activity.activities.first + subscribeToWidgetNotifications() } public func send(_ action: Action) { @@ -99,8 +110,10 @@ public final class RecordingViewModel { showCompleteAlert?() case .cancelButtonTapped: stopTimer() + stopAmplitudeUpdates() waveformTask?.cancel() waveformTask = nil + endLiveActivity() actionTask?.cancel() actionTask = Task { try? await repository.cancelRecording() @@ -111,8 +124,10 @@ public final class RecordingViewModel { actionTask = Task { do { stopTimer() + stopAmplitudeUpdates() waveformTask?.cancel() waveformTask = nil + endLiveActivity() let voiceRecord = try await repository.finishRecording() let voiceNote = try voiceNoteUseCase.create(voiceRecord) coordinator?.finishRecording(voiceNote: voiceNote) @@ -132,6 +147,8 @@ public final class RecordingViewModel { state.recordingStartDate = .now state.recordingState = .recording startTimer() + startLiveActivity() + startAmplitudeUpdates() waveformTask?.cancel() waveformTask = Task { [weak self] in @@ -152,7 +169,9 @@ public final class RecordingViewModel { do { try await repository.pauseRecording() stopTimer() + stopAmplitudeUpdates() state.recordingState = .paused + updateLiveActivity(isPaused: true) } catch { send(.errorOccurred(error)) } @@ -164,7 +183,9 @@ public final class RecordingViewModel { do { try await repository.resumeRecording() startTimer() + startAmplitudeUpdates() state.recordingState = .recording + updateLiveActivity(isPaused: false) } catch { send(.errorOccurred(error)) } @@ -186,4 +207,117 @@ public final class RecordingViewModel { timerTask?.cancel() timerTask = nil } + + // MARK: - Live Activity Amplitude Update + + /// Live Activity에 amplitude를 주기적으로 반영합니다. + /// ActivityKit는 업데이트 빈도에 제한이 있으므로 1초 간격으로 업데이트합니다. + private func startAmplitudeUpdates() { + amplitudeUpdateTask?.cancel() + amplitudeUpdateTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, let self else { return } + updateLiveActivity(isPaused: false) + } + } + } + + private func stopAmplitudeUpdates() { + amplitudeUpdateTask?.cancel() + amplitudeUpdateTask = nil + } + + // MARK: - Live Activity + + private func startLiveActivity() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + let attributes = RecordingActivityAttributes( + title: state.title, + startDate: state.displayStartDate + ) + + let initialContentState = RecordingActivityAttributes.ContentState( + duration: state.recordingDuration, + isPaused: false, + amplitude: state.amplitude + ) + + let content = ActivityContent(state: initialContentState, staleDate: nil) + + do { + activeActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + } catch { + AppLogger.error("Failed to start Live Activity: \(error.localizedDescription)") + } + } + + private func updateLiveActivity(isPaused: Bool) { + guard let activeActivity else { return } + + let updatedContentState = RecordingActivityAttributes.ContentState( + duration: state.recordingDuration, + isPaused: isPaused, + amplitude: state.amplitude + ) + + let content = ActivityContent(state: updatedContentState, staleDate: nil) + + Task { @MainActor in + await activeActivity.update(content) + } + } + + private func endLiveActivity() { + guard let activeActivity else { return } + + let finalContentState = RecordingActivityAttributes.ContentState( + duration: state.recordingDuration, + isPaused: state.recordingState == .paused, + amplitude: state.amplitude + ) + + let content = ActivityContent(state: finalContentState, staleDate: nil) + + Task { @MainActor in + await activeActivity.end(content, dismissalPolicy: .immediate) + self.activeActivity = nil + } + } + + // MARK: - Widget IPC (Darwin Notification) + + /// Widget Extension에서 보내는 Darwin Notification을 구독합니다. + /// NotificationCenter.default는 동일 프로세스 내에서만 작동하므로, + /// 별도 프로세스인 Widget Extension과는 Darwin Notification을 사용합니다. + private func subscribeToWidgetNotifications() { + let pauseObservation = DarwinNotificationCenter.observe(.pauseRecording) { [weak self] in + Task { @MainActor [weak self] in + self?.handlePauseFromWidget() + } + } + let resumeObservation = DarwinNotificationCenter.observe(.resumeRecording) { [weak self] in + Task { @MainActor [weak self] in + self?.handleResumeFromWidget() + } + } + darwinObservations = [pauseObservation, resumeObservation] + } + + @MainActor + private func handlePauseFromWidget() { + guard state.recordingState == .recording else { return } + pauseRecording() + } + + @MainActor + private func handleResumeFromWidget() { + guard state.recordingState == .paused else { return } + resumeRecording() + } } diff --git a/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift b/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift index a97a9af7..e0a2b003 100644 --- a/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift +++ b/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift @@ -448,6 +448,7 @@ extension TrashViewModel { } func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift index 3e8733f7..e7937eae 100644 --- a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift @@ -98,6 +98,8 @@ func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift index 04032e13..c8c4a7b4 100644 --- a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift @@ -30,9 +30,16 @@ public final class VoiceNoteViewModel { @ObservationIgnored private var voiceNoteObservationTask: Task? @ObservationIgnored + private var grammarProgressTask: Task? + @ObservationIgnored private var wasPlayingBeforeSeek = false public weak var coordinator: VoiceNoteCoordinatorDelegate? + // MARK: - Grammar Progress + + public private(set) var grammarProgress: (current: Int, total: Int)? + private var realTimeCorrectedSections: [TranscriptSection]? + // MARK: - UseCases private let voiceNoteUseCase: any VoiceNoteUseCase @@ -65,6 +72,11 @@ public final class VoiceNoteViewModel { fetchFolderName() observeVoiceNote() checkMLXSupport() + + // 분석이 미완료 상태인 경우 상세 화면 진입 시 자동으로 분석(STT/문법교정/요약)을 재개하도록 큐잉합니다. + if voiceNote.analysisState.isUnfinished { + voiceNoteUseCase.enqueue(id: voiceNote.id) + } } private func checkMLXSupport() { @@ -79,6 +91,8 @@ public final class VoiceNoteViewModel { playbackObservationTask = nil voiceNoteObservationTask?.cancel() voiceNoteObservationTask = nil + grammarProgressTask?.cancel() + grammarProgressTask = nil stop() } @@ -296,8 +310,20 @@ public final class VoiceNoteViewModel { let stream = try voiceNoteUseCase.observe(id: voiceNote.id) for await note in stream { let folderChanged = voiceNote.folderID != note.folderID - voiceNote = note + let stateChanged = voiceNote.analysisState != note.analysisState + + self.voiceNote = note if folderChanged { fetchFolderName() } + + if stateChanged { + if note.analysisState == .grammarChecking { + self.grammarProgress = nil + self.realTimeCorrectedSections = nil + } else if note.analysisState != .grammarChecking { + self.grammarProgress = nil + self.realTimeCorrectedSections = nil + } + } } } catch { errorMessage = error.localizedDescription @@ -305,6 +331,40 @@ public final class VoiceNoteViewModel { } } + private func setupGrammarProgressObservation() { + grammarProgressTask?.cancel() + grammarProgressTask = Task { + let sequence = NotificationCenter.default + .notifications(named: NSNotification.Name("GrammarCorrectionProgress")) + for await notification in sequence { + guard let userInfo = notification.userInfo, + let transcriptID = userInfo["transcriptID"] as? UUID, + let originalTranscriptID = voiceNote.transcript?.id, + transcriptID == originalTranscriptID else { continue } + + let current = userInfo["current"] as? Int ?? 0 + let total = userInfo["total"] as? Int ?? 0 + let sectionIndex = userInfo["sectionIndex"] as? Int ?? 0 + let correctedText = userInfo["correctedText"] as? String ?? "" + + await MainActor.run { + self.grammarProgress = (current, total) + + if self.realTimeCorrectedSections == nil { + self.realTimeCorrectedSections = self.voiceNote.transcript?.sections + } + if var sections = self.realTimeCorrectedSections, sectionIndex < sections.count { + sections[sectionIndex] = TranscriptSection( + timestamp: sections[sectionIndex].timestamp, + text: correctedText + ) + self.realTimeCorrectedSections = sections + } + } + } + } + } + private func play() { do { try playbackRepository.play() @@ -400,6 +460,9 @@ public extension VoiceNoteViewModel { var scriptSections: [TranscriptSection] { if editingMode == .script { return editableScriptSections } + if voiceNote.analysisState == .grammarChecking, let realTime = realTimeCorrectedSections { + return realTime + } return voiceNote.transcript?.sections ?? [] } diff --git a/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift b/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift index 04166ac3..9dbe7ec3 100644 --- a/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift +++ b/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift @@ -356,6 +356,8 @@ private struct FakeVoiceNoteUseCase: VoiceNoteUseCase { func regenerateSummary(id _: UUID) {} + func enqueue(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} diff --git a/Widget/Project.swift b/Widget/Project.swift new file mode 100644 index 00000000..a6a25ef0 --- /dev/null +++ b/Widget/Project.swift @@ -0,0 +1,94 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +private let widgetScheme = Scheme.scheme( + name: "ChaGokWidget", + shared: true, + buildAction: .buildAction( + targets: [.target("ChaGokWidget")], + findImplicitDependencies: true + ) +) + +private let widgetTestsScheme = Scheme.scheme( + name: "ChaGokWidgetTests", + shared: true, + buildAction: .buildAction( + targets: [.target("ChaGokWidgetTests")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("ChaGokWidgetTests"), parallelization: .disabled) + ]) +) + +private let widgetTarget = Target.target( + name: "ChaGokWidget", + destinations: [.iPhone], + product: .appExtension, + bundleId: "\(bundleId).widget", + deploymentTargets: deploymentTargets, + infoPlist: .extendingDefault( + with: [ + "CFBundleDisplayName": "ChaGokWidget", + "NSSupportsLiveActivities": true, + "NSExtension": [ + "NSExtensionPointIdentifier": "com.apple.widgetkit-extension" + ] + ] + ), + sources: ["Sources/**/*.swift"], + dependencies: [ + .project(target: "Core", path: "../Core"), + .project(target: "Domain", path: "../Domain"), + .project(target: "Presentation", path: "../Presentation") + ], + settings: .settings( + base: [ + "CODE_SIGN_IDENTITY": "Apple Development", + "PROVISIONING_PROFILE_SPECIFIER": "match Development com.yongms.ChaGokChaGok.widget" + ], + configurations: [ + .debug(name: "Debug", settings: [ + "CODE_SIGN_IDENTITY": "Apple Development", + "PROVISIONING_PROFILE_SPECIFIER": "match Development com.yongms.ChaGokChaGok.widget" + ]), + .release(name: "Release", settings: [ + "CODE_SIGN_IDENTITY": "Apple Distribution", + "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.yongms.ChaGokChaGok.widget" + ]) + ], + defaultSettings: .recommended + ) +) + +private let widgetTestsTarget = Target.target( + name: "ChaGokWidgetTests", + destinations: [.iPhone], + product: .unitTests, + bundleId: "\(bundleId).widgetTests", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: [ + "Tests/**/*.swift", + "Sources/**/*.swift", + "!Sources/WidgetBundle.swift" + ], + dependencies: [ + .project(target: "Domain", path: "../Domain"), + .project(target: "Presentation", path: "../Presentation") + ] +) + +let project = Project( + name: "Widget", + settings: settings, + targets: [ + widgetTarget, + widgetTestsTarget + ], + schemes: [ + widgetScheme, + widgetTestsScheme + ] +) diff --git a/Widget/Sources/ChaGokWidgetAppIntents.swift b/Widget/Sources/ChaGokWidgetAppIntents.swift new file mode 100644 index 00000000..201403a0 --- /dev/null +++ b/Widget/Sources/ChaGokWidgetAppIntents.swift @@ -0,0 +1,13 @@ +import AppIntents +import Presentation + +/// Widget Extension에서 Presentation 프레임워크의 AppIntent(LiveActivityIntent)를 +/// 시스템이 인식할 수 있도록 등록합니다. +/// +/// `includedPackages`를 통해 Presentation 프레임워크의 `PresentationAppIntents`를 참조하여, +/// 빌드 시스템이 프레임워크 내 ToggleRecordingIntent의 메타데이터를 이 Extension에도 연결합니다. +struct ChaGokWidgetAppIntents: AppIntentsPackage { + static var includedPackages: [any AppIntentsPackage.Type] { + [PresentationAppIntents.self] + } +} diff --git a/Widget/Sources/Recording/AudioMeterView.swift b/Widget/Sources/Recording/AudioMeterView.swift new file mode 100644 index 00000000..984854f9 --- /dev/null +++ b/Widget/Sources/Recording/AudioMeterView.swift @@ -0,0 +1,177 @@ +import Foundation +import SwiftUI + +/// Live Activity / Dynamic Island에서 현재 녹음이 진행 중임을 시각적으로 보여주는 오디오 미터 뷰입니다. +/// amplitude(0.0~1.0) 값을 기반으로 각 바의 높이를 결정합니다. +/// +/// 디자인 특징: +/// - 중앙 바가 가장 높고, 양 끝은 dot(원) 형태로 짧습니다. +/// - 바마다 고유한 변동을 적용하여 유기적인 웨이브폼을 표현합니다. +/// - RoundedRectangle로 끝이 둥근 바를 표현합니다. +public struct AudioMeterView: View { + let barCount: Int + let amplitude: Double + let isPaused: Bool + let activeColor: Color + + public init( + barCount: Int = 27, + amplitude: Double, + isPaused: Bool, + activeColor: Color = Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255) + ) { + self.barCount = barCount + self.amplitude = amplitude + self.isPaused = isPaused + self.activeColor = activeColor + } + + public var body: some View { + HStack(alignment: .center, spacing: 2) { + ForEach(0 ..< barCount, id: \.self) { index in + RoundedRectangle(cornerRadius: barWidth / 2) + .fill(isPaused ? Color.white.opacity(0.3) : activeColor) + .frame(width: barWidth, height: barHeight(for: index)) + } + } + } + + private var barWidth: CGFloat { + 3.0 + } + + private func barHeight(for index: Int) -> CGFloat { + let dotHeight: CGFloat = barWidth // 양 끝의 dot은 정사각형(원) + let maxHeight: CGFloat = 22.0 + + guard !isPaused else { + return dotHeight + } + + let clampedAmplitude = min(max(amplitude, 0.0), 1.0) + + // ────────────────────────────────────────────── + // 시각적 amplitude 보정 + // AVAudioRecorder의 dB→선형 변환 결과는 일반 음성에서 0.01~0.1 수준으로 매우 작습니다. + // pow(x, 0.3) 곡선을 적용하여 작은 값을 시각적으로 증폭합니다. + // 예) 0.01 → 0.25, 0.05 → 0.42, 0.1 → 0.50, 0.3 → 0.67, 1.0 → 1.0 + // ────────────────────────────────────────────── + let boostedAmplitude = pow(clampedAmplitude, 0.3) + + // 중앙에서의 거리를 0.0(중앙) ~ 1.0(양 끝)으로 정규화 + let midIndex = Double(barCount - 1) / 2.0 + let distanceFromCenter = abs(Double(index) - midIndex) + let normalizedDistance = midIndex > 0 ? distanceFromCenter / midIndex : 0.0 + + // 중앙 가중치: 중앙(1.0) → 양 끝(0.0) + // pow를 사용하여 양 끝에서 급격히 낮아지는 곡선 적용 + let centerWeight = pow(1.0 - normalizedDistance, 1.5) + + // 바마다 고유한 변동 패턴 (index 기반 의사 랜덤) + let seed = Double(index * 13 + 5) + let variation = sin(seed) * 0.5 + 0.5 // 0.0 ~ 1.0 + let jitter = 0.6 + variation * 0.8 // 0.6 ~ 1.4 + + // 최종 높이: boostedAmplitude × centerWeight × jitter로 유기적 변화 + let targetHeight = dotHeight + (maxHeight - dotHeight) * boostedAmplitude * centerWeight * jitter + + return max(dotHeight, min(maxHeight, targetHeight)) + } +} + +/// Live Activity / recording UI에 적합한 재사용 가능한 오디오 미터 컴포넌트입니다. +/// level 값에 따라 각 바의 높이가 랜덤하게 변하며, 자연스럽게 애니메이션됩니다. +public struct LiveAudioMeter: View { + private let level: CGFloat + private let barCount: Int + private let maxHeight: CGFloat + private let minHeight: CGFloat + + @State + private var heights: [CGFloat] = [] + + public init( + level: CGFloat, + barCount: Int = 7, + maxHeight: CGFloat = 24, + minHeight: CGFloat = 4 + ) { + self.level = level + self.barCount = max(1, barCount) + self.maxHeight = max(minHeight, maxHeight) + self.minHeight = max(1, minHeight) + } + + public var body: some View { + HStack(alignment: .center, spacing: 2) { + ForEach(0 ..< barCount, id: \.self) { index in + RoundedRectangle(cornerRadius: 1.5) + .fill(Color.white.opacity(0.9)) + .frame(width: 3, height: barHeight(for: index)) + } + } + .onAppear { + refreshHeights(animated: false) + } + .onChange(of: level) { _, _ in + refreshHeights(animated: true) + } + .onChange(of: barCount) { _, _ in + refreshHeights(animated: false) + } + .onReceive(Timer.publish(every: 0.16, tolerance: 0.05, on: .main, in: .common).autoconnect()) { _ in + refreshHeights(animated: true) + } + } + + private func barHeight(for index: Int) -> CGFloat { + guard heights.indices.contains(index) else { + return Self.makeHeight( + for: index, + barCount: barCount, + level: level, + maxHeight: maxHeight, + minHeight: minHeight + ) + } + + return heights[index] + } + + private func refreshHeights(animated: Bool) { + let nextHeights = (0 ..< barCount).map { index in + Self.makeHeight( + for: index, + barCount: barCount, + level: level, + maxHeight: maxHeight, + minHeight: minHeight + ) + } + + if animated { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + heights = nextHeights + } + } else { + heights = nextHeights + } + } + + static func makeHeight( + for index: Int, + barCount: Int, + level: CGFloat, + maxHeight: CGFloat, + minHeight: CGFloat + ) -> CGFloat { + let normalizedLevel = min(max(level, 0.0), 1.0) + let amplitudeLimit = minHeight + (maxHeight - minHeight) * normalizedLevel + let availableRange = max(0.0, amplitudeLimit - minHeight) + let variation = CGFloat(index % 5 + 1) / 5.0 + let randomFactor = CGFloat.random(in: 0.3 ... 1.0) * (0.65 + variation * 0.35) + let rawHeight = minHeight + availableRange * randomFactor + + return min(maxHeight, max(minHeight, rawHeight)) + } +} diff --git a/Widget/Sources/Recording/RecordingActivityWidget.swift b/Widget/Sources/Recording/RecordingActivityWidget.swift new file mode 100644 index 00000000..fe07c80b --- /dev/null +++ b/Widget/Sources/Recording/RecordingActivityWidget.swift @@ -0,0 +1,191 @@ +import ActivityKit +import AppIntents +import Core +import Domain +import Presentation +import SwiftUI +import WidgetKit + +struct RecordingActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RecordingActivityAttributes.self) { context in + LockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + // 다이내믹 아일랜드 확장형 (왼쪽) + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 8) { + Button(intent: ToggleRecordingIntent()) { + Circle() + .fill(LinearGradient( + colors: [ + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: context.state.isPaused ? "play.fill" : "pause.fill") + .foregroundColor(.white) + .font(.headline) + ) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.subheadline) + .foregroundColor(.white) + + TimerView( + duration: context.state.duration, + isPaused: context.state.isPaused, + color: Color.white.opacity(0.7) + ) + .font(.body) + .multilineTextAlignment(.leading) + } + } + } + + // 다이내믹 아일랜드 확장형 (오른쪽) + DynamicIslandExpandedRegion(.trailing) { + AudioMeterView( + barCount: 17, + amplitude: Double(context.state.amplitude), + isPaused: context.state.isPaused + ) + .padding(.trailing, 8) + .frame(maxHeight: .infinity) + } + } compactLeading: { + // 다이내믹 아일랜드 최소형 (왼쪽 버튼) + Image(systemName: "waveform") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(LinearGradient( + colors: [ + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + } compactTrailing: { + // 다이내믹 아일랜드 최소형 (오른쪽 타이머) + ZStack(alignment: .trailing) { + TimerView( + duration: context.state.duration, + isPaused: context.state.isPaused, + color: .white + ) + .font(.caption.bold()) + } + .frame(width: 38, height: 24) + .clipped() + } minimal: { + // 다이내믹 아일랜드 독립형 최소 형태 + Button(intent: ToggleRecordingIntent()) { + Circle() + .fill(LinearGradient( + colors: [ + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 24, height: 24) + .overlay( + Image(systemName: context.state.isPaused ? "play.fill" : "pause.fill") + .foregroundColor(.white) + .font(.caption2.bold()) + ) + } + .buttonStyle(.plain) + } + } + } +} + +struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 16) { + Button(intent: ToggleRecordingIntent()) { + Circle() + .fill(LinearGradient( + colors: [ + Color(red: 111 / 255, green: 83 / 255, blue: 253 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 48, height: 48) + .overlay( + Image(systemName: context.state.isPaused ? "play.fill" : "pause.fill") + .foregroundColor(.white) + .font(.title2.bold()) + ) + } + .buttonStyle(.plain) + + // 가운데: 녹음 타이틀 및 시작 날짜/시간 + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.headline) + .foregroundColor(.white) + Text(context.attributes.startDate) + .font(.caption) + .foregroundColor(Color.white.opacity(0.6)) + } + + Spacer() + + // 오른쪽: 실시간 타이머 및 오디오 미터 + VStack(alignment: .trailing) { + TimerView( + duration: context.state.duration, + isPaused: context.state.isPaused, + color: .white + ) + .multilineTextAlignment(.trailing) + .font(.title.bold()) + Spacer() + AudioMeterView( + barCount: 15, + amplitude: Double(context.state.amplitude), + isPaused: context.state.isPaused + ) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .activityBackgroundTint(Color(red: 22 / 255, green: 21 / 255, blue: 27 / 255).opacity(0.95)) + } +} + +struct TimerView: View { + let duration: TimeInterval + let isPaused: Bool + let color: Color + + var body: some View { + if isPaused { + Text(duration.durationString) + .monospacedDigit() + .foregroundColor(color) + .multilineTextAlignment(.trailing) + } else { + // 시스템 타이머 연동 (과거 시작 시점으로부터 카운트업 진행) + Text(Date(timeIntervalSinceNow: -duration), style: .timer) + .monospacedDigit() + .foregroundColor(color) + } + } +} diff --git a/Widget/Sources/WidgetBundle.swift b/Widget/Sources/WidgetBundle.swift new file mode 100644 index 00000000..53bc4643 --- /dev/null +++ b/Widget/Sources/WidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct ChaGokWidgetBundle: WidgetBundle { + var body: some Widget { + RecordingActivityWidget() + } +} diff --git a/Widget/Tests/RecordingActivityWidgetTests.swift b/Widget/Tests/RecordingActivityWidgetTests.swift new file mode 100644 index 00000000..762241c2 --- /dev/null +++ b/Widget/Tests/RecordingActivityWidgetTests.swift @@ -0,0 +1,20 @@ +import Domain +import XCTest + +final class RecordingActivityWidgetTests: XCTestCase { + func test_라이브오디오미터는_설정된_높이_범위_내에서_높이를_반환한다() { + let heights = (0 ..< 7).map { + LiveAudioMeter.makeHeight( + for: $0, + barCount: 7, + level: 0.8, + maxHeight: 24, + minHeight: 4 + ) + } + + XCTAssertEqual(heights.count, 7) + XCTAssertTrue(heights.allSatisfy { $0 >= 4 && $0 <= 24 }) + XCTAssertTrue(heights.contains { $0 > 4 }) + } +} diff --git a/Workspace.swift b/Workspace.swift index c7fef1ae..63a4be8a 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -8,6 +8,7 @@ let workspace = Workspace( "Core", "Domain", "Data", - "Presentation" + "Presentation", + "Widget" ] ) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dc87cde0..3d7281e8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,7 +18,10 @@ default_platform(:ios) platform :ios do desc "코드 사이닝 인증서 및 프로비저닝 프로파일 동기화" lane :sync_certificates do |options| - match(type: options[:type] || "appstore") + match( + type: options[:type] || "appstore", + app_identifier: ["com.yongms.ChaGokChaGok", "com.yongms.ChaGokChaGok.widget"] + ) end def marketing_version @@ -70,7 +73,8 @@ platform :ios do teamID: "78QTJM9AD7", signingStyle: "manual", provisioningProfiles: { - "com.yongms.ChaGokChaGok" => "match AppStore com.yongms.ChaGokChaGok" + "com.yongms.ChaGokChaGok" => "match AppStore com.yongms.ChaGokChaGok", + "com.yongms.ChaGokChaGok.widget" => "match AppStore com.yongms.ChaGokChaGok.widget" } } )