diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..4a4205df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: 버그 제보 +about: 동작 오류나 예상과 다른 동작을 알려주세요 +title: '[Bug] ' +labels: 🐛 Bug +--- + +## 버그 설명 +> 어떤 문제가 발생하는지 간단히 + +- + +## 재현 방법 +1. '...' 로 이동 +2. '...' 클릭 +3. '...' 까지 스크롤 +4. 오류 확인 + +## 예상 동작 +> 정상이라면 어떻게 되어야 하는지 + +- + +## 실제 동작 +> 실제로 어떻게 되는지 + +- + +## 스크린샷 / 로그 +> 가능하면 첨부 + +## 환경 +- 기기/시뮬레이터: +- OS 버전: +- Xcode 버전 (해당 시): diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..64eb98dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: true +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..253394ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: 기능 제안 +about: 새 기능이나 개선 아이디어를 제안해 주세요 +title: '[Feature] ' +labels: 🚀 Enhancement +--- + +## 요청 내용 +> 어떤 기능/개선을 원하는지 한 줄로 + +- + +## 배경/동기 +> 왜 이게 필요한지 (선택) + +- + +## 제안하는 동작 +> 구체적으로 어떻게 동작하면 좋을지 + +- + +## 대안 +> 고려한 다른 방법이 있다면 (선택) + +- diff --git a/.github/ISSUE_TEMPLATE/todo-template.md b/.github/ISSUE_TEMPLATE/todo-template.md new file mode 100644 index 00000000..664cd5dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/todo-template.md @@ -0,0 +1,10 @@ +--- +name: Todo Template +about: Use this template for tracking a list of tasks or to-dos. +title: '[TODO] ' +--- + +## ✅ 작업 목록 (To-Do List) +- [ ] +- [ ] +- [ ] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..da1cbd3b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,54 @@ +## ✨ 작업 요약 +> 한 줄로 무엇을 추가/변경했는지 + +- + +## 📋 구체적인 내용 +- 추가/변경된 동작: +- 영향 받는 화면/모듈: + +--- + +## 🔗 연관 이슈 + +- closed # + +--- + +## 🧩 설계·구현 노트 +> 왜 이렇게 구현했는지, 대안과 비교한 점 (선택) + +- **선택한 방식** +- +- **고려했다가 제외한 방식** +- + +--- + +## ✅ 확인 사항 +> PR 전 직접 확인한 것 + +- [ ] 정상 플로우 동작 확인 +- [ ] 엣지 케이스 / 빈 값·에러 처리 확인 +- [ ] (UI 변경 시) 스크린샷 또는 GIF 첨부 +- [ ] (로직 추가 시) 테스트 추가 여부 + +--- + +## 👀 리뷰 포인트 + +- [ ] 설계·구조 적절성 +- [ ] 로직·엣지 케이스 +- [ ] 예외/에러 핸들링 +- [ ] UI/UX (해당 시) +- [ ] 성능·의존성 (해당 시) + +**특히 봐줬으면 하는 부분** + +- + +--- + +## 📚 참고 + +- diff --git a/.github/release-notes.yml b/.github/release-notes.yml new file mode 100644 index 00000000..4ee7a5ea --- /dev/null +++ b/.github/release-notes.yml @@ -0,0 +1,41 @@ +changelog: + exclude: + labels: + - skip-changelog + - internal + authors: + - github-actions[bot] + - dependabot[bot] + + categories: + - title: "⚙️ Logic & Business" + labels: + - "⚙️ Logic" + + - title: "🚀 Enhancements" + labels: + - "🚀 Enhancement" + + - title: "🎨 UI & Layout" + labels: + - "🎨 UI" + + - title: "🐛 Bug Fixes" + labels: + - "🐛 Bug" + + - title: "♻️ Refactoring" + labels: + - "♻️ Refactor" + + - title: "📝 Documentation" + labels: + - "📝 Documentation" + + - title: "🧪 Testing" + labels: + - "🧪 Test" + + - title: "❓ Others" + labels: + - "*" diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 00000000..c79dec42 --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,38 @@ +name: Beta + +on: + push: + branches: ["dev"] + +permissions: + id-token: write + contents: read + +jobs: + beta: + runs-on: macos-26 + environment: FastLane + env: + TUIST_XDG_STATE_HOME: /tmp/tuist-state + steps: + - uses: actions/checkout@v6 + + - uses: jdx/mise-action@v4 + + - name: Install Ruby gems + run: bundle install + + - name: Tuist auth login + run: tuist auth login + + - name: Tuist setup cache + run: tuist setup cache + + - name: Deploy to TestFlight + env: + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }} + run: bundle exec fastlane beta diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..92b0d7fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + pull_request: + branches: ["dev", "main"] + push: + branches: ["dev", "main"] + +permissions: + id-token: write + contents: read + +jobs: + format: + uses: ./.github/workflows/format.yml + + test: + needs: format + uses: ./.github/workflows/test.yml \ No newline at end of file diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..779f7e8f --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,19 @@ +name: Format + +on: + workflow_call: + +jobs: + format: + runs-on: macos-26 + steps: + - uses: actions/checkout@v6 + + - uses: jdx/mise-action@v4 + + - name: Swift format + run: | + swiftformat . + git diff --stat + git diff + git diff --exit-code \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6764c4f0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +on: + push: + branches: ["main"] + +permissions: + id-token: write + contents: write + pull-requests: read + +jobs: + app-store: + name: Deploy to App Store + runs-on: macos-26 + environment: FastLane + env: + TUIST_XDG_STATE_HOME: /tmp/tuist-state + steps: + - uses: actions/checkout@v6 + + - uses: jdx/mise-action@v4 + + - name: Install Ruby gems + run: bundle install + + - name: Tuist auth login + run: tuist auth login + + - name: Tuist setup cache + run: tuist setup cache + + - name: Sync Certificates + env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }} + run: bundle exec fastlane sync_certificates type:appstore + + - name: Deploy to App Store + env: + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + run: bundle exec fastlane release + + github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: app-store + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # 모든 히스토리를 가져와야 릴리즈 노트 생성이 가능함 + + # 🏷️ Tuist 설정에서 버전 정보 추출 (v1.0.0 형식) + - name: Extract Version + id: get_version + run: | + VERSION=$(grep 'public let version =' Tuist/ProjectDescriptionHelpers/Config.swift | sed 's/.*"\(.*\)".*/\1/') + echo "VERSION=v$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + # 🚀 GitHub Release 생성 및 자동 노트 작성 + - name: Create Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.get_version.outputs.VERSION }} + run: | + # 이미 해당 태그가 존재하는지 확인 + if gh release view "$TAG_NAME" > /dev/null 2>&1; then + echo "Release $TAG_NAME already exists. Skipping..." + else + gh release create "$TAG_NAME" \ + --title "$TAG_NAME" \ + --generate-notes + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..47bdafde --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Test + +on: + workflow_call: + +permissions: + id-token: write + contents: read + +jobs: + test: + runs-on: macos-26 + env: + TUIST_XDG_STATE_HOME: /tmp/tuist-state + steps: + - uses: actions/checkout@v6 + + - uses: jdx/mise-action@v4 + + - name: Tuist auth login + run: tuist auth login + + - name: Tuist setup cache + run: tuist setup cache + + - name: Install dependencies + run: tuist install + + - name: Run tests + run: tuist test + + - name: Upload Tuist session logs on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: tuist-session-logs + path: /tmp/tuist-state/tuist/sessions/ + if-no-files-found: ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index 44c5720a..0a251409 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,87 @@ +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Xcode ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +### Xcode Patch ### +*.xcodeproj +*.xcworkspace +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +### Wiki ### +iOS.wiki/ + + +### Tuist derived files ### +graph.dot +Derived/ +.tuist-cache + +### Tuist managed dependencies ### +Tuist/.build + +### VSCode ### +.vscode buildServer.json -.DS_Store \ No newline at end of file + +### Claude ### +CLAUDE.md + +### Fastlane ### +fastlane/.env.secret +fastlane/.env.*.secret +fastlane/*.p8 +fastlane/report.xml +fastlane/README.md \ No newline at end of file diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..8d4bab23 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,3 @@ +[tools] +tuist = "4.158.0" +ruby = "3.3.7" \ No newline at end of file diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 00000000..ef490c4a --- /dev/null +++ b/.swiftformat @@ -0,0 +1,63 @@ +# SwiftFormat configuration for ChaGok - Final Safe Version + +# [기본 스타일] +--swift-version 6.0 +--indent 4 +--max-width 120 +--linebreaks lf +--trim-whitespace always +--trailing-commas never +--allman false + +# [빈 줄 및 레이아웃 관리] +--enable blankLinesAtStartOfScope +--enable blankLinesAtEndOfScope +--type-blank-lines remove +--else-position same-line + +# [가독성 설정 - 안전 위주] +--self remove +--modifier-order public,private,static,final +--pattern-let inline +--semicolons never +--indent-case false +--blank-line-after-switch-case always + +# [추천 추가 규칙 - 가독성 향상] +--acronyms ID,URL,UUID,JSON # 약어 대소문자 일관성 (UserId -> userID) +--decimal-grouping 3,6 # 숫자 가독성 (1000000 -> 1_000_000) +--enable typeSugar # 단축 문법 사용 (Array -> [Int]) +--enable redundantInit # 불필요한 .init 제거 (MyClass.init() -> MyClass()) +--wrap-collections before-first # 긴 배열/딕셔너리 가로 길이 초과 시 줄바꿈 +--enable consistentSwitchCaseSpacing # switch case 간 간격 일관성 + +# [어트리뷰트 배치] +--func-attributes prev-line +--type-attributes prev-line +--stored-var-attributes prev-line +--computed-var-attributes prev-line + +# [Import 정렬 - @testable 우선] +--import-grouping testable-first +--enable sortImports + +# [비활성화 (중요!) - 코드 변형 방지] +--disable redundantReturn +--disable redundantVoidReturnType +--disable redundantType +--disable redundantFileprivate +--disable unusedArguments +--disable redundantLet +--disable wrapFunctionBodies + +# [줄바꿈 및 인자 관리] +--wrap-arguments before-first +--wrap-parameters before-first +--hex-literal-case lowercase + +# ============================ +# Exclude +# ============================ + +# Tuist 매니페스트 파일 제외 (Tuist API는 bundleId 등 고유 네이밍을 사용) +--exclude Tuist,**/Project.swift,Workspace.swift \ No newline at end of file diff --git a/App/Project.swift b/App/Project.swift new file mode 100644 index 00000000..5bbfc828 --- /dev/null +++ b/App/Project.swift @@ -0,0 +1,107 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +private let appScheme = Scheme.scheme( + name: "App", + shared: true, + buildAction: .buildAction( + targets: [.target("App")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("AppTests"), parallelization: .disabled) + ]), + runAction: .runAction(executable: .target("App")) +) + +private let appTestsScheme = Scheme.scheme( + name: "AppTests", + shared: true, + buildAction: .buildAction( + targets: [.target("AppTests")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("AppTests"), parallelization: .disabled) + ]) +) + +private let appTarget = Target.target( + name: "App", + destinations: .iOS, + product: .app, + bundleId: bundleId, + deploymentTargets: deploymentTargets, + infoPlist: .extendingDefault( + with: [ + "CFBundleDisplayName": Plist.Value(stringLiteral: displayName), + "CFBundleShortVersionString": "$(MARKETING_VERSION)", + "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", + "UILaunchScreen": Plist.Value( + dictionaryLiteral: ( + "UIColorName", Plist.Value(stringLiteral: "") + ), + ("UIImageName", Plist.Value(stringLiteral: "")) + ), + "UIUserInterfaceStyle": Plist.Value(stringLiteral: style), + "NSMicrophoneUsageDescription": "음성 메모를 녹음하기 위해 마이크 권한이 필요합니다.", + "NSSpeechRecognitionUsageDescription": "음성을 텍스트로 변환하기 위해 음성 인식 권한이 필요합니다.", + "ITSAppUsesNonExemptEncryption": false, + "UIBackgroundModes": ["audio"] + ] + ), + sources: ["Sources/**/*.swift"], + resources: ["Resources/**"], + entitlements: .dictionary([ + "com.apple.developer.kernel.increased-memory-limit": .boolean(true), + "com.apple.developer.kernel.extended-virtual-addressing": .boolean(true) + ]), + dependencies: [ + .project(target: "Core", path: "../Core"), + .project(target: "Domain", path: "../Domain"), + .project(target: "Presentation", path: "../Presentation"), + .project(target: "Data", path: "../Data") + ], + settings: .settings( + base: ["ASSETCATALOG_COMPILER_APPICON_NAME": "ChaGok"], + configurations: [ + .debug(name: "Debug", settings: [ + "CODE_SIGN_IDENTITY": "Apple Development", + "PROVISIONING_PROFILE_SPECIFIER": "match Development com.yongms.ChaGokChaGok" + ]), + .release(name: "Release", settings: [ + "CODE_SIGN_IDENTITY": "Apple Distribution", + "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.yongms.ChaGokChaGok" + ]) + ], + defaultSettings: .recommended + ) +) + +private let appTestsTarget = Target.target( + name: "AppTests", + destinations: .iOS, + product: .unitTests, + bundleId: "\(bundleId).AppTests", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Tests/**/*.swift"], + dependencies: [.target(name: "App")] +) + +let project = Project( + name: "App", + options: .options( + defaultKnownRegions: ["ko", "en"], + developmentRegion: "ko" + ), + settings: settings, + targets: [ + appTarget, + appTestsTarget + ], + schemes: [ + appScheme, + appTestsScheme + ] +) diff --git a/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..fdbed1ae --- /dev/null +++ b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCF", + "green" : "0x49", + "red" : "0x75" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/100.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 00000000..ad10368f Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 00000000..11d4967d Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/114.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 00000000..51a03268 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000..8fa4c66c Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/128.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/128.png new file mode 100644 index 00000000..24e196ca Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/144.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 00000000..3a462d7f Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 00000000..143766d2 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/16.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 00000000..14ed8591 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 00000000..258dd7e3 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000..4d706e96 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 00000000..03518d3a Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/256.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 00000000..9272565d Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 00000000..97d6bd1a Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/32.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 00000000..9b8d01fb Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 00000000..3992b515 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/50.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 00000000..c2845c0c Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/512.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 00000000..ab93a8b0 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/57.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 00000000..41ce6df1 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 00000000..8ac21405 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 00000000..1816f6c2 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/64.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 00000000..b8b60c80 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/64.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/72.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 00000000..77552ee1 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 00000000..8be1319d Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000..3015ba02 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 00000000..a3eb185a Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..bbf91d18 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,218 @@ +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" + }, + { + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" + }, + { + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" + }, + { + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" + }, + { + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "filename" : "16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ChaGok/App/Resources/Assets.xcassets/Contents.json b/App/Resources/Assets.xcassets/Contents.json similarity index 100% rename from ChaGok/App/Resources/Assets.xcassets/Contents.json rename to App/Resources/Assets.xcassets/Contents.json diff --git a/App/Resources/ChaGok.icon/Assets/app icon clear.svg b/App/Resources/ChaGok.icon/Assets/app icon clear.svg new file mode 100644 index 00000000..ffe20d1f --- /dev/null +++ b/App/Resources/ChaGok.icon/Assets/app icon clear.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Resources/ChaGok.icon/icon.json b/App/Resources/ChaGok.icon/icon.json new file mode 100644 index 00000000..c839ff69 --- /dev/null +++ b/App/Resources/ChaGok.icon/icon.json @@ -0,0 +1,42 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:0.09571,0.08425,0.17449,1.00000", + "display-p3:0.15367,0.10316,0.53703,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "app icon clear.svg", + "name" : "app icon clear" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/ChaGok/App/Resources/Preview Content/Preview Assets.xcassets/Contents.json b/App/Resources/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from ChaGok/App/Resources/Preview Content/Preview Assets.xcassets/Contents.json rename to App/Resources/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/App/Resources/appstore.png b/App/Resources/appstore.png new file mode 100644 index 00000000..11d4967d Binary files /dev/null and b/App/Resources/appstore.png differ diff --git a/App/Resources/playstore.png b/App/Resources/playstore.png new file mode 100644 index 00000000..ab93a8b0 Binary files /dev/null and b/App/Resources/playstore.png differ diff --git a/App/Sources/AppDIContainer.swift b/App/Sources/AppDIContainer.swift new file mode 100644 index 00000000..a419184a --- /dev/null +++ b/App/Sources/AppDIContainer.swift @@ -0,0 +1,227 @@ +import Core +import Data +import Domain +import Foundation +import Presentation +import UIKit + +/// App의 모든 의존성(의존성 그래프)을 구성하고 객체를 생성하는 Pure DI 컨테이너입니다. +/// 외부 라이브러리에 의존하지 않고 생성자 주입(Constructor Injection) 방식으로 객체를 조립합니다. +@MainActor +public final class AppDIContainer { + /// InfraStructure + private lazy var store = UserDefaultsKeyValueStoreService() + private lazy var storageService = FileManagerStorageService() + private let localDataBase: CoreDataLocalDataBase + private lazy var mlxProvider = MLXModelProvider(storageService: storageService) + + /// Repository + private lazy var languageRepository = DefaultLanguageRepository(store: store) + private lazy var voiceRecordRepository = DefaultVoiceRecordRepository(storageService: storageService) + private lazy var checkFirstLaunchRepository = DefaultCheckFirstLaunchRepository(store: store) + private lazy var folderRepository = DefaultFolderRepository(context: localDataBase.container.viewContext) + private lazy var voiceNoteRepository = DefaultVoiceNoteRepository(context: localDataBase.container.viewContext) + private lazy var sttRepository = DefaultSTTRepository( + storageService: storageService, + languageRepository: languageRepository + ) + private lazy var summaryRepository = DefaultSummaryRepository() + private lazy var mlxSummaryRepository = DefaultMLXSummaryRepository( + provider: mlxProvider + ) + private lazy var whisperProvider = WhisperKitProvider( + storageService: storageService, + languageRepository: languageRepository + ) + private lazy var availableSupportModelRepository = DefaultAvailableModelSupportRepository( + mlxProvider: mlxProvider, + whisperProvider: whisperProvider + ) + + private lazy var sttWhisperRepository = DefaultWhisperSTTRepository( + storageService: storageService, + dataSource: whisperProvider + ) + + private lazy var mlxOnDeviceRepository = DefaultMlxOnDeviceRepository( + provider: mlxProvider + ) + + private lazy var whisperOnDeviceRepository = DefaultWhisperOnDeviceRepository( + provider: whisperProvider + ) + /// Analysis (Domain Service) + private(set) lazy var voiceNoteAnalysisService = DefaultVoiceNoteAnalysisService( + voiceNoteRepository: voiceNoteRepository, + sttRepository: sttWhisperRepository, + summaryRepository: mlxSummaryRepository, + languageRepository: languageRepository + ) + + /// UseCase + private lazy var folderUseCase = DefaultFolderUseCase(repository: folderRepository) + private lazy var voiceNoteUseCase = DefaultVoiceNoteUseCase( + repository: voiceNoteRepository, + folderRepository: folderRepository, + analysisService: voiceNoteAnalysisService + ) + private lazy var onDeviceStatusUseCase = DefaultOnDeviceStatusUseCase( + whisperRepository: whisperOnDeviceRepository, + mlxRepository: mlxOnDeviceRepository + ) + public init() throws { + localDataBase = try CoreDataLocalDataBase() + } + + // MARK: - Whisper 모델 ( preload , download ) Status + + public func isWhisperModelDownloaded() async -> Bool { + let status = await onDeviceStatusUseCase.checkStatus(model: .whisper) + if case .downloading = status.storage { + return false + } + + do { + _ = try await whisperProvider.getDownloadPath() + return true + } catch { + AppLogger.error(error) + return false + } + } + + public func preloadWhisperKit() { + Task { @MainActor in + await whisperProvider.preload() + } + } + + // MARK: - Repository + + func makeCheckFirstLaunchRepository() -> CheckFirstLaunchRepository { + checkFirstLaunchRepository + } + + func makeVoiceRecordRepository() -> VoiceRecordRepository { + voiceRecordRepository + } + + // MARK: - ViewModel + + public func makeOnBoardingViewModel() -> OnBoardingViewModel { + OnBoardingViewModel( + languageRepository: languageRepository, + voiceRecordRepository: voiceRecordRepository, + sttRepository: sttRepository, + checkFirstLaunchRepository: checkFirstLaunchRepository, + folderUseCase: folderUseCase, + availableSupportModelRepository: availableSupportModelRepository, + mlxRepository: mlxOnDeviceRepository + ) + } + + public func makeRecordingViewModel() -> RecordingViewModel { + RecordingViewModel( + repository: voiceRecordRepository, + voiceNoteUseCase: voiceNoteUseCase + ) + } + + public func makeVoiceNoteViewModel(voiceNote: VoiceNote, isTrashMode: Bool = false) -> VoiceNoteViewModel { + VoiceNoteViewModel( + voiceNote: voiceNote, + voiceNoteUseCase: voiceNoteUseCase, + folderUseCase: folderUseCase, + playbackRepository: DefaultVoiceRecordPlaybackRepository(storageService: storageService), + availableSupportModelRepository: availableSupportModelRepository, + isTrashMode: isTrashMode + ) + } + + public func makeMainViewModel() -> MainViewModel { + return MainViewModel( + microphoneRepository: voiceRecordRepository, + voiceNoteUseCase: voiceNoteUseCase, + folderUseCase: folderUseCase + ) + } + + public func makeTrashViewModel() -> TrashViewModel { + return TrashViewModel( + folderUseCase: folderUseCase, + voiceNoteUseCase: voiceNoteUseCase + ) + } + + public func makeMyFolderViewModel(_ category: CategoryToggle) -> FolderViewModel { + return FolderViewModel( + category: category, + folderUseCase: folderUseCase + ) + } + + public func makeMyFolderDetailViewModel(_ folder: Folder, isTrashMode: Bool = false) -> FolderDetailViewModel { + return FolderDetailViewModel( + title: folder.name, + folderID: folder.id, + isTrashMode: isTrashMode, + voiceNoteUseCase: voiceNoteUseCase + ) + } + + public func makeMoveFolderListViewModel( + voiceNotes: [VoiceNote], + onComplete: ((String) -> Void)? = nil + ) -> MoveFolderListViewModel { + return MoveFolderListViewModel( + voiceNotes: voiceNotes, + folderUseCase: folderUseCase, + voiceNoteUseCase: voiceNoteUseCase, + onComplete: onComplete + ) + } + + public func makeNewFolderViewModel() -> NewFolderViewModel { + return NewFolderViewModel(folderUseCase: folderUseCase) + } + + public func makeSearchViewModel( + type: SearchViewModel.SearchType, + items: [ContentItem], + isTrashMode: Bool = false + ) -> SearchViewModel { + return SearchViewModel( + type: type, + items: items, + isTrashMode: isTrashMode, + folderRepository: folderRepository + ) + } + + public func makeChaGokAlertViewModel(environment: ChaGokAlertViewModel.AlertEnvironment) -> ChaGokAlertViewModel { + return ChaGokAlertViewModel(environment: environment) + } + + public func makeDownloadOnDeviceViewModel() -> DownloadOnDeviceViewModel { + return DownloadOnDeviceViewModel( + onDeviceStatusUseCase: onDeviceStatusUseCase + ) + } + + public func makeSettingViewModel() -> SettingViewModel { + return SettingViewModel( + languageRepository: languageRepository, + availableModelRepository: availableSupportModelRepository, + onDeviceStatusUseCase: onDeviceStatusUseCase + ) + } + + #if DEBUG + public func seedDebugDataIfNeeded() { + DebugSeeder( + folderRepository: folderRepository, + voiceNoteRepository: voiceNoteRepository + ).seedIfNeeded() + } + #endif +} diff --git a/App/Sources/AppDelegate.swift b/App/Sources/AppDelegate.swift new file mode 100644 index 00000000..7e322125 --- /dev/null +++ b/App/Sources/AppDelegate.swift @@ -0,0 +1,53 @@ +import Core +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + private(set) var dependencyContainer: AppDIContainer? + private(set) var initializationError: Error? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + configureNavigationBarAppearance() + do { + #if DEBUG + AppLogger.info("진짜 폴더 위치: \(NSHomeDirectory())") + #endif + dependencyContainer = try AppDIContainer() + } catch { + AppLogger.error(error) + initializationError = error + } + return true + } + + func applicationWillTerminate(_ application: UIApplication) { + dependencyContainer?.voiceNoteAnalysisService.cancelAll() + } + + private func configureNavigationBarAppearance() { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = UIColor.black + appearance.shadowColor = .clear + + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + UINavigationBar.appearance().compactAppearance = appearance + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let config = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + config.delegateClass = SceneDelegate.self + return config + } +} diff --git a/App/Sources/Coordinator/AppCoordinator.swift b/App/Sources/Coordinator/AppCoordinator.swift new file mode 100644 index 00000000..46f985ff --- /dev/null +++ b/App/Sources/Coordinator/AppCoordinator.swift @@ -0,0 +1,69 @@ +import Presentation +import UIKit + +@MainActor +final class AppCoordinator: BaseCoordinator { + let window: UIWindow + let dependencyContainer: AppDIContainer + + init(window: UIWindow, dependencyContainer: AppDIContainer) { + self.window = window + self.dependencyContainer = dependencyContainer + let presenter = UINavigationController() + presenter.isToolbarHidden = true + presenter.isNavigationBarHidden = true + + super.init(presenter: presenter) + self.window.rootViewController = presenter + self.window.makeKeyAndVisible() + } + + override func start() { + let checkFirstLaunchRepository = dependencyContainer.makeCheckFirstLaunchRepository() + if checkFirstLaunchRepository.checkIsFirstLaunch() { + startOnboarding() + } else { + startMain() + } + } + + private func startOnboarding() { + let viewModel = dependencyContainer.makeOnBoardingViewModel() + viewModel.onBoardingCoordinator = self + let onBoardingVC = OnBoardingViewController(vm: viewModel) + presenter.setViewControllers([onBoardingVC], animated: false) + } + + func showMain() { + clearChildCoordinator() + startMain() + + UIView.transition( + with: window, + duration: 0.3, + options: .transitionCrossDissolve, + animations: nil, + completion: nil + ) + } + + private func startMain() { + #if DEBUG + dependencyContainer.seedDebugDataIfNeeded() + #endif + let coordinator = MainCoordinator( + presenter: presenter, + dependencyContainer: dependencyContainer + ) + store(coordinator: coordinator) + coordinator.start() + } +} + +// MARK: - OnboardingCoordinatorDelegate + +extension AppCoordinator: OnboardingCoordinatorDelegate { + func finishOnBoarding() { + showMain() + } +} diff --git a/App/Sources/Coordinator/BaseCoordinator.swift b/App/Sources/Coordinator/BaseCoordinator.swift new file mode 100644 index 00000000..1766b2c5 --- /dev/null +++ b/App/Sources/Coordinator/BaseCoordinator.swift @@ -0,0 +1,47 @@ +import UIKit + +@MainActor +class BaseCoordinator: Identifiable { + let id: UUID + private(set) var childCoordinators: [UUID: Any] = [:] + let presenter: ControllerType + + init(id: UUID = UUID(), presenter: ControllerType) { + self.id = id + self.presenter = presenter + } + + func start() { + preconditionFailure("Not implemented") + } +} + +extension BaseCoordinator { + func store(coordinator: BaseCoordinator) { + let existCoordinator = childCoordinators.contains(where: { key, value -> Bool in + key == coordinator.id + }) + + if !existCoordinator { + childCoordinators[coordinator.id] = coordinator + } + } + + func free(coordinator: BaseCoordinator) { + let existCoordinator = childCoordinators.contains(where: { key, value -> Bool in + key == coordinator.id + }) + + if existCoordinator { + childCoordinators[coordinator.id] = nil + } + } + + func clearChildCoordinator() { + childCoordinators = [:] + } + + func childCoordinator(forKey key: UUID) -> T? { + return childCoordinators.first(where: { $0.key == key })?.value as? T + } +} diff --git a/App/Sources/Coordinator/MainCoordinator.swift b/App/Sources/Coordinator/MainCoordinator.swift new file mode 100644 index 00000000..288f332f --- /dev/null +++ b/App/Sources/Coordinator/MainCoordinator.swift @@ -0,0 +1,254 @@ +import Core +import Domain +import Presentation +import SwiftUI +import UIKit + +@MainActor +final class MainCoordinator: BaseCoordinator { + private let dependencyContainer: AppDIContainer + + init( + presenter: UINavigationController, + dependencyContainer: AppDIContainer + ) { + self.dependencyContainer = dependencyContainer + super.init(presenter: presenter) + } + + override func start() { + let mainVM = dependencyContainer.makeMainViewModel() + let mainVC = MainViewController(vm: mainVM) + mainVM.mainCoordinator = self + mainVM.alertCoordinator = self + presenter.isNavigationBarHidden = false + presenter.setViewControllers([mainVC], animated: false) + } +} + +// MARK: - RecordingCoordinating + +extension MainCoordinator: RecordingCoordinating { + func cancelRecording() { + presenter.dismiss(animated: true) + } + + func finishRecording(voiceNote: VoiceNote) { + presenter.dismiss(animated: true) { [weak self] in + guard let self else { return } + let voiceNoteVM = dependencyContainer.makeVoiceNoteViewModel(voiceNote: voiceNote) + voiceNoteVM.coordinator = self + let voiceNoteVC = VoiceNoteViewController(viewModel: voiceNoteVM) + presenter.pushViewController(voiceNoteVC, animated: true) + } + } +} + +// MARK: - MainViewCoordinator + +extension MainCoordinator: MainCoordinatorDelegate { + // TODO: Push + + func pushTrashView() { + let trashVM = dependencyContainer.makeTrashViewModel() + trashVM.coordinator = self + trashVM.alertCoordinator = self + let trashVC = TrashViewController(vm: trashVM) + presenter.pushViewController(trashVC, animated: true) + } + + func pushMyFolderView(category: CategoryToggle) { + let myFolderVM = dependencyContainer.makeMyFolderViewModel(category) + myFolderVM.coordinator = self + myFolderVM.alertCoordinator = self + let myFolderVC = FolderViewController(vm: myFolderVM) + presenter.pushViewController(myFolderVC, animated: true) + } + + func pushVoiceNoteView(voiceNote: VoiceNote, isTrashMode: Bool = false) { + let voiceNoteVM = dependencyContainer.makeVoiceNoteViewModel(voiceNote: voiceNote, isTrashMode: isTrashMode) + voiceNoteVM.coordinator = self + let voiceNoteVC = VoiceNoteViewController(viewModel: voiceNoteVM) + presenter.pushViewController(voiceNoteVC, animated: true) + } + + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem]) { + let searchVM = dependencyContainer.makeSearchViewModel(type: type, items: items) + searchVM.coordinator = self + let searchVC = SearchViewController(vm: searchVM) + + presenter.pushViewController(searchVC, animated: true) + } + + func pushSettingView() { + let settingVM = dependencyContainer.makeSettingViewModel() + settingVM.coordinator = self + let settingVC = SettingViewController(vm: settingVM) + + presenter.pushViewController(settingVC, animated: true) + } + + // TODO: Present + + func presentRecodingView() { + let navController = UINavigationController() + Task { + let isModelDownloaded = await dependencyContainer.isWhisperModelDownloaded() + if isModelDownloaded { + dependencyContainer.preloadWhisperKit() + let viewModel = dependencyContainer.makeRecordingViewModel() + viewModel.coordinator = self + viewModel.alertCoordinator = self + let recordingVC = RecordingViewController(viewModel: viewModel) + navController.isNavigationBarHidden = false + navController.modalPresentationStyle = .fullScreen + navController.setViewControllers([recordingVC], animated: false) + } else { + let viewModel = dependencyContainer.makeDownloadOnDeviceViewModel() + viewModel.coordinator = self + let downloadVC = DownloadOnDeviceViewController(vm: viewModel) + navController.isNavigationBarHidden = true + navController.modalPresentationStyle = .pageSheet + navController.setViewControllers([downloadVC], animated: false) + + if let sheet = navController.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = true + } + } + + presenter.present(navController, animated: true) + } + } +} + +// MARK: - FolderCoordinatorDelegate + +extension MainCoordinator: FolderCoordinatorDelegate { + func pop() { + presenter.popViewController(animated: true) + } + + func pushMyFolderDetailView(_ folder: Folder) { + let myFolderDetailVM = dependencyContainer.makeMyFolderDetailViewModel(folder) + myFolderDetailVM.coordinator = self + myFolderDetailVM.alertCoordinator = self + let myFolderDetailVC = FolderDetailViewController(vm: myFolderDetailVM) + presenter.pushViewController(myFolderDetailVC, animated: true) + } +} + +// MARK: - FolderDetailCoordinatorDelegate + +extension MainCoordinator: FolderDetailCoordinatorDelegate { + func presentFolderList(with voiceNotes: [VoiceNote], onComplete: ((String) -> Void)?) { + presentMoveFolder(voiceNotes: voiceNotes, onComplete: onComplete, topDetentStyle: .belowNavigationBar) + } +} + +// MARK: - TrashCoordinatorDelegate + +extension MainCoordinator: TrashCoordinatorDelegate { + func pushMyFolderDetailView(_ folder: Folder, isHidden: Bool) { + let myFolderDetailVM = dependencyContainer.makeMyFolderDetailViewModel(folder, isTrashMode: isHidden) + myFolderDetailVM.coordinator = self + myFolderDetailVM.alertCoordinator = self + let myFolderDetailVC = FolderDetailViewController(vm: myFolderDetailVM) + presenter.pushViewController(myFolderDetailVC, animated: true) + } + + func pushSearchView( + type: Presentation.SearchViewModel.SearchType, + items: [ContentItem], + isHidden: Bool + ) { + let searchVM = dependencyContainer.makeSearchViewModel(type: type, items: items, isTrashMode: isHidden) + searchVM.coordinator = self + let searchVC = SearchViewController(vm: searchVM) + presenter.pushViewController(searchVC, animated: true) + } +} + +// MARK: - VoiceNoteCoordinatorDelegate + +extension MainCoordinator: VoiceNoteCoordinatorDelegate { + func presentMoveFolder(for voiceNote: VoiceNote, onComplete: ((String) -> Void)?) { + presentMoveFolder(voiceNotes: [voiceNote], onComplete: onComplete, topDetentStyle: .belowSegmentControl) + } +} + +// MARK: - SearchCoordinatorDelegate + +extension MainCoordinator: SearchCoordinatorDelegate { + func pushMyFolderDetailView(_ folder: Folder, isTrashMode: Bool) { + let myFolderDetailVM = dependencyContainer.makeMyFolderDetailViewModel(folder, isTrashMode: isTrashMode) + myFolderDetailVM.coordinator = self + myFolderDetailVM.alertCoordinator = self + let myFolderDetailVC = FolderDetailViewController(vm: myFolderDetailVM) + presenter.pushViewController(myFolderDetailVC, animated: true) + } +} + +// MARK: - ChaGokAlertCoordinatorDelegate + +extension MainCoordinator: ChaGokAlertCoordinatorDelegate { + func presentAlert( + environment: ChaGokAlertViewModel.AlertEnvironment, + delegate: ChaGokAlertButtonTappedDelegate? + ) { + let viewModel = dependencyContainer.makeChaGokAlertViewModel(environment: environment) + viewModel.coordinator = self + let alertVC = ChaGokAlertViewController(vm: viewModel) + alertVC.delegate = delegate + var topVC: UIViewController = presenter + while let presented = topVC.presentedViewController { + topVC = presented + } + topVC.present(alertVC, animated: true) + } +} + +// MARK: - DownloadWhisperCoordinatorDelegate + +extension MainCoordinator: DownloadOnDeviceCoordinatorDelegate { + func dismissSheet() { + presenter.dismiss(animated: true) + } +} + +// MARK: - SettingCoordinatorDelegate + +extension MainCoordinator: SettingCoordinatorDelegate { + func pushTermsOfUseView() { + let termsVC = TermsOfUseViewController() + presenter.pushViewController(termsVC, animated: true) + } + + func pushPrivacyPolicyView() { + let privacyVC = PrivacyPolicyViewController() + presenter.pushViewController(privacyVC, animated: true) + } +} + +// MARK: - Helpers + +private extension MainCoordinator { + func presentMoveFolder( + voiceNotes: [VoiceNote], + onComplete: ((String) -> Void)?, + topDetentStyle: MoveFolderCoordinator.TopDetentStyle + ) { + let coordinator = MoveFolderCoordinator( + dependencyContainer: dependencyContainer, + voiceNotes: voiceNotes, + onComplete: onComplete, + topDetentStyle: topDetentStyle, + onFinish: { [weak self] coordinator in + self?.free(coordinator: coordinator) + } + ) + store(coordinator: coordinator) + coordinator.start() + presenter.present(coordinator.presenter, animated: true) + } +} diff --git a/App/Sources/Coordinator/MoveFolderCoordinator.swift b/App/Sources/Coordinator/MoveFolderCoordinator.swift new file mode 100644 index 00000000..2701e75a --- /dev/null +++ b/App/Sources/Coordinator/MoveFolderCoordinator.swift @@ -0,0 +1,133 @@ +import Core +import Domain +import Presentation +import UIKit + +@MainActor +final class MoveFolderCoordinator: BaseCoordinator { + enum TopDetentStyle { + /// 부모 네비게이션 바 바로 아래까지. + case belowNavigationBar + /// VoiceNote 상세의 세그먼트 컨트롤 바로 아래까지. + case belowSegmentControl + } + + private let dependencyContainer: AppDIContainer + private let voiceNotes: [VoiceNote] + private let onComplete: ((String) -> Void)? + private let topDetentStyle: TopDetentStyle + private let onFinish: (MoveFolderCoordinator) -> Void + + init( + dependencyContainer: AppDIContainer, + voiceNotes: [VoiceNote], + onComplete: ((String) -> Void)?, + topDetentStyle: TopDetentStyle = .belowNavigationBar, + onFinish: @escaping (MoveFolderCoordinator) -> Void + ) { + self.dependencyContainer = dependencyContainer + self.voiceNotes = voiceNotes + self.onComplete = onComplete + self.topDetentStyle = topDetentStyle + self.onFinish = onFinish + + let nav = UINavigationController() + nav.isNavigationBarHidden = true + super.init(presenter: nav) + } + + override func start() { + let viewModel = dependencyContainer.makeMoveFolderListViewModel( + voiceNotes: voiceNotes, + onComplete: onComplete + ) + viewModel.coordinator = self + let viewController = MoveFolderListViewController(viewModel: viewModel) + presenter.setViewControllers([viewController], animated: false) + + if let sheet = presenter.sheetPresentationController { + sheet.detents = moveFolderListDetents + sheet.prefersGrabberVisible = true + } + } + + /// 부모 화면 상단 safe area + `topDetentStyle`에 해당하는 추가 inset만큼 아래까지 올라오는 커스텀 detent 포함. + private var moveFolderListDetents: [UISheetPresentationController.Detent] { + let additionalTopInset = additionalTopInset(for: topDetentStyle) + return [ + .medium(), + .custom(identifier: .init("belowParentTop")) { [weak presenter, additionalTopInset] context in + guard let parentNav = presenter?.presentingViewController as? UINavigationController, + let topView = parentNav.topViewController?.view, + let window = topView.window + else { + return context.maximumDetentValue + } + let sheetTopY = topView.safeAreaInsets.top + additionalTopInset + let sheetHeight = window.bounds.height - sheetTopY + return min(sheetHeight, context.maximumDetentValue) + } + ] + } + + private func additionalTopInset(for style: TopDetentStyle) -> CGFloat { + switch style { + case .belowNavigationBar: + return 0 + case .belowSegmentControl: + return Constant.underlineSegmentedControlTopMargin + Constant.underlineSegmentedControlHeight + } + } +} + +// MARK: - MoveFolderListCoordinatorDelegate + +extension MoveFolderCoordinator: MoveFolderListCoordinatorDelegate { + func dismiss() { + presenter.dismiss(animated: true) { [weak self] in + guard let self else { return } + onFinish(self) + } + } + + func pushNewFolder() { + guard let sheet = presenter.sheetPresentationController else { return } + + let viewModel = dependencyContainer.makeNewFolderViewModel() + viewModel.coordinator = self + let newFolderVC = NewFolderViewController(viewModel: viewModel) + newFolderVC.view.layoutIfNeeded() + + presenter.pushViewController(newFolderVC, animated: true) + + sheet.animateChanges { + sheet.detents = [.custom { [weak newFolderVC] _ in + newFolderVC?.preferredContentSize.height + }] + } + } +} + +// MARK: - NewFolderCoordinatorDelegate + +extension MoveFolderCoordinator: NewFolderCoordinatorDelegate { + func cancel() { + guard let sheet = presenter.sheetPresentationController else { return } + + presenter.popViewController(animated: true) + + sheet.animateChanges { + sheet.detents = moveFolderListDetents + } + } + + func folderCreated() { + guard let sheet = presenter.sheetPresentationController else { return } + + presenter.popViewController(animated: true) + + sheet.animateChanges { + sheet.detents = moveFolderListDetents + } + } +} diff --git a/App/Sources/Debug/DebugSeeder.swift b/App/Sources/Debug/DebugSeeder.swift new file mode 100644 index 00000000..1b853637 --- /dev/null +++ b/App/Sources/Debug/DebugSeeder.swift @@ -0,0 +1,350 @@ +#if DEBUG + import AVFoundation + import Core + import Domain + import Foundation + + @MainActor + struct DebugSeeder { + private static let didSeedKey = "debug_did_seed_v1" + + let folderRepository: any FolderRepository + 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 + } + try performSeed() + defaults.set(true, forKey: Self.didSeedKey) + AppLogger.info("시드 데이터 생성 완료") + } catch { + AppLogger.error("시드 데이터 생성 실패: \(error)") + } + } + + // MARK: - Private + + private func performSeed() throws { + let workFolder = try folderRepository.create(Folder(name: "업무")) + let personalFolder = try folderRepository.create(Folder(name: "개인")) + let studyFolder = try folderRepository.create(Folder(name: "학습")) + let meetingFolder = try folderRepository.create(Folder(name: "회의록")) + + let now = Date.now + let h: TimeInterval = 3600 + let specs: [Spec] = [ + // MARK: 업무 + + Spec( + folderID: workFolder.id, + title: "팀 주간 미팅 - 2026 Q1 회고와 Q2 목표 설정", + createdAt: now.addingTimeInterval(-h * 4), + texts: SeedContent.weeklyMeeting, + summaryLines: [ + "Q1 핵심 지표 달성률 92퍼센트, 아웃풋 대비 아웃컴 개선 필요", + "Q2에는 온보딩 퍼널 개선과 신규 사용자 리텐션 지표를 최우선으로 설정", + "팀 간 커뮤니케이션 비용을 줄이기 위해 주간 싱크 포맷 재정비" + ], + keywords: ["주간회의", "Q1회고", "Q2목표", "리텐션", "퍼널", "OKR", "팀싱크"], + analysisState: .completed + ), + Spec( + folderID: workFolder.id, + title: "신규 프로젝트 킥오프 미팅", + createdAt: now.addingTimeInterval(-h * 12), + texts: SeedContent.projectKickoff, + summaryLines: [ + "프로젝트 범위와 마일스톤, 리스크 및 담당자 최종 확정", + "첫 데모는 4주 뒤 내부 리뷰, 정식 런칭은 8주 뒤 목표" + ], + keywords: ["킥오프", "스코프", "마일스톤", "담당자", "리스크", "런칭", "데모", "타임라인"], + analysisState: .completed + ), + Spec( + folderID: workFolder.id, + title: "1on1 - 매니저 면담", + createdAt: now.addingTimeInterval(-h * 26), + texts: SeedContent.oneOnOne, + summaryLines: [ + "다음 분기 커리어 목표로 시니어 엔지니어 승진 준비 합의" + ], + keywords: ["1on1", "커리어", "성장", "피드백", "시니어", "승진"], + analysisState: .completed + ), + Spec( + folderID: workFolder.id, + title: "고객사 온보딩 미팅 - Acme Corp", + createdAt: now.addingTimeInterval(-h * 50), + texts: SeedContent.customerMeeting, + summaryLines: [ + "SSO 연동 스펙과 일정 공유", + "다음 미팅 전까지 관리자 대시보드 프로토타입 공유", + "계약 범위 외 추가 요구사항은 별도 견적 프로세스 진행" + ], + keywords: ["고객사", "온보딩", "SSO", "대시보드", "프로토타입", "계약"], + analysisState: .completed + ), + Spec( + folderID: workFolder.id, + title: "Apple Intelligence 기술 조사 녹음", + createdAt: now.addingTimeInterval(-h * 72), + texts: SeedContent.techResearch, + summaryLines: [], + keywords: ["AppleIntelligence", "Foundation", "온디바이스LLM", "프라이버시"], + analysisState: .summarizationFailed + ), + Spec( + folderID: workFolder.id, + title: "오늘 녹음한 메모 (정리 전)", + createdAt: now.addingTimeInterval(-h * 1), + texts: [], + summaryLines: [], + keywords: [], + analysisState: .pending + ), + + // MARK: 개인 + + Spec( + folderID: personalFolder.id, + title: "이번 주 회고 일기", + createdAt: now.addingTimeInterval(-h * 20), + texts: SeedContent.weeklyReflection, + summaryLines: [ + "업무 집중 시간 확보 성공, 다만 운동 루틴은 2회만 지킴", + "다음 주는 자기계발 시간 블록을 캘린더에 미리 잡기로 결심" + ], + keywords: ["회고", "습관", "루틴", "시간관리", "운동"], + analysisState: .completed + ), + Spec( + folderID: personalFolder.id, + title: "다음 달 제주도 여행 계획 브레인스토밍", + createdAt: now.addingTimeInterval(-h * 40), + texts: SeedContent.travelPlanning, + summaryLines: [ + "항공편과 숙소는 이번 주 안으로 예약 마감", + "첫째 날은 서귀포 중심, 둘째 날 동쪽, 셋째 날 서쪽 코스로 동선 확정", + "우중 대비 실내 일정은 박물관과 카페 중심으로 백업 준비" + ], + keywords: ["여행", "제주도", "일정", "숙소", "항공편", "맛집", "렌터카"], + analysisState: .completed + ), + Spec( + folderID: personalFolder.id, + title: "운동 루틴 정리", + createdAt: now.addingTimeInterval(-h * 60), + texts: SeedContent.workoutRoutine, + summaryLines: [ + "주 4회 분할 루틴, 유산소 20분 병행으로 최종 확정" + ], + keywords: ["운동", "루틴", "헬스", "유산소", "분할"], + analysisState: .completed + ), + Spec( + folderID: personalFolder.id, + title: "독서 메모 - 아주 작은 습관의 힘", + createdAt: now.addingTimeInterval(-h * 90), + texts: SeedContent.bookNotes, + summaryLines: [ + "1퍼센트의 개선이 복리로 누적되는 핵심 원리 정리", + "환경 설계와 정체성 기반 습관 두 축으로 행동 설계" + ], + keywords: ["독서", "습관", "복리", "정체성", "환경설계", "자기계발"], + analysisState: .completed + ), + + // MARK: 학습 + + Spec( + folderID: studyFolder.id, + title: "iOS 강의 녹음 - Swift 동시성 딥다이브", + createdAt: now.addingTimeInterval(-h * 34), + texts: SeedContent.swiftConcurrencyLecture, + summaryLines: [ + "actor와 Sendable은 데이터 레이스 방지의 핵심 도구", + "Task.isCancelled 체크와 Typed Throws로 안전한 비동기 경계 설계", + "MainActor 격리를 과도하게 쓰면 성능 병목이 될 수 있음" + ], + keywords: ["Swift6", "동시성", "actor", "Sendable", "MainActor", "TypedThrows", "Task"], + analysisState: .completed + ), + Spec( + folderID: studyFolder.id, + title: "면접 대비 - 아키텍처 패턴 정리", + createdAt: now.addingTimeInterval(-h * 48), + texts: SeedContent.architectureStudy, + summaryLines: [ + "Clean Architecture의 의존성 방향 규칙이 핵심", + "MVVM, MVI, TCA 각각의 트레이드오프 비교 정리" + ], + keywords: ["아키텍처", "MVVM", "MVI", "TCA", "CleanArchitecture", "면접"], + analysisState: .completed + ), + Spec( + folderID: studyFolder.id, + title: "Core Data 마이그레이션 실습 녹음", + createdAt: now.addingTimeInterval(-h * 65), + texts: SeedContent.coreDataStudy, + summaryLines: [], + keywords: ["CoreData", "마이그레이션", "NSPersistentContainer"], + analysisState: .transcribed + ), + + // MARK: 회의록 + + Spec( + folderID: meetingFolder.id, + title: "디자인 리뷰 - 온보딩 화면 4차 개선안", + createdAt: now.addingTimeInterval(-h * 8), + texts: SeedContent.designReview, + summaryLines: [ + "온보딩 3단계 순서 변경과 CTA 카피 개선", + "권한 요청 시점을 명확한 가치 제공 직후로 이동", + "다크 모드 대응 미비 항목 8건 다음 스프린트 백로그로 편입" + ], + keywords: ["디자인리뷰", "온보딩", "CTA", "권한", "다크모드", "UX"], + analysisState: .completed + ), + Spec( + folderID: meetingFolder.id, + title: "월간 전사 공유 - 2026년 3월", + createdAt: now.addingTimeInterval(-h * 120), + texts: SeedContent.allHands, + summaryLines: [ + "분기별 주요 지표와 전략 우선순위 공유", + "신규 입사자 소개 및 하반기 채용 계획 안내" + ], + keywords: ["전사공유", "월간", "지표", "전략", "채용", "입사자"], + analysisState: .completed + ), + Spec( + folderID: meetingFolder.id, + title: "긴급 장애 대응 미팅 녹음", + createdAt: now.addingTimeInterval(-h * 36), + texts: SeedContent.incidentMeeting, + summaryLines: [], + keywords: ["장애", "인시던트", "포스트모템"], + analysisState: .transcriptionFailed + ) + ] + + for spec in specs { + try createSeededNote(spec: spec) + } + } + + private func createSeededNote(spec: Spec) throws { + let sections = 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( + createdAt: spec.createdAt, + audioFilePath: audioPath, + duration: max(duration, 2.5) + ) + let baseNote = VoiceNote( + title: spec.title, + createdAt: spec.createdAt, + updatedAt: spec.createdAt, + folderID: spec.folderID, + voiceRecord: record, + analysisState: spec.analysisState + ) + let created = try voiceNoteRepository.create(baseNote) + + let transcript: Transcript? = shouldIncludeTranscript(for: spec.analysisState) && !sections.isEmpty + ? Transcript(sections: sections) + : nil + let summary: Summary? = spec.analysisState == .completed && !spec.summaryLines.isEmpty + ? Summary(text: spec.summaryLines.joined(separator: "\n")) + : nil + let keywords = spec.keywords.map { Keyword(noteID: created.id, word: $0) } + + let updated = VoiceNote( + id: created.id, + title: spec.title, + createdAt: spec.createdAt, + updatedAt: spec.createdAt, + folderID: spec.folderID, + voiceRecord: created.voiceRecord, + keywords: keywords, + transcript: transcript, + summary: summary, + analysisState: spec.analysisState + ) + _ = try voiceNoteRepository.update(updated) + } + + private func shouldIncludeTranscript(for state: AnalysisState) -> Bool { + switch state { + case .pending, .transcribing, .transcriptionFailed: + return false + case .transcribed, .summarizing, .regenerating, .completed, .summarizationFailed: + return true + } + } + + private func makeSilentAudioFile(duration: Double) throws -> String { + let directory = "VoiceRecords" + let fileName = "seed-\(UUID().uuidString).m4a" + let docURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let directoryURL = docURL.appendingPathComponent(directory) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + let fileURL = directoryURL.appendingPathComponent(fileName) + + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 44100.0, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 64000 + ] + let file = try AVAudioFile( + forWriting: fileURL, + settings: settings, + commonFormat: .pcmFormatFloat32, + interleaved: false + ) + let frameCount = AVAudioFrameCount(file.processingFormat.sampleRate * duration) + guard let buffer = AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: frameCount + ) else { + throw SeedError.audioGenerationFailed + } + buffer.frameLength = frameCount + try file.write(from: buffer) + + return "\(directory)/\(fileName)" + } + + // MARK: - Types + + private struct Spec { + let folderID: UUID + let title: String + let createdAt: Date + let texts: [String] + let summaryLines: [String] + let keywords: [String] + let analysisState: AnalysisState + } + + private enum SeedError: Error { + case audioGenerationFailed + } + } +#endif diff --git a/App/Sources/Debug/SeedContent.swift b/App/Sources/Debug/SeedContent.swift new file mode 100644 index 00000000..64d32b5a --- /dev/null +++ b/App/Sources/Debug/SeedContent.swift @@ -0,0 +1,507 @@ +#if DEBUG + import Domain + import Foundation + + enum SeedContent { + static func buildSections(texts: [String]) -> [TranscriptSection] { + var result: [TranscriptSection] = [] + var timestamp: TimeInterval = 0 + for text in texts { + result.append(TranscriptSection(timestamp: timestamp, text: text)) + timestamp += TimeInterval(3 + (text.count % 4)) + } + return result + } + + // MARK: - 업무 + + static let weeklyMeeting: [String] = [ + "안녕하세요 여러분 오늘 주간 미팅 시작하겠습니다. 지난 분기 회고부터 차근차근 보고 Q2 방향성까지 정리하는 시간이 될 것 같아요.", + "먼저 Q1 핵심 지표부터 공유드리면 신규 가입자 수는 목표 대비 108퍼센트, 유료 전환율은 목표 대비 92퍼센트 수준이었습니다.", + "유료 전환율이 조금 아쉬운데 이 부분에 대한 분석은 그로스 팀에서 별도로 드릴 예정입니다.", + "리텐션 지표는 전 분기 대비 소폭 상승했지만 업계 평균에는 여전히 못 미치고 있는 상황입니다.", + "특히 7일 리텐션이 42퍼센트 정도인데 이 부분이 Q2에 집중적으로 개선해야 할 영역입니다.", + "다음으로 프로덕트 팀 업데이트입니다. 지난 분기에 배포한 기능 중 사용률이 높았던 건 자동 요약 기능과 폴더 공유 기능이었습니다.", + "자동 요약 기능은 출시 2주 만에 DAU의 38퍼센트가 사용해 봤을 정도로 반응이 좋았습니다.", + "반대로 반응이 미지근했던 기능은 음성 메모 태깅 기능이었는데 UI 접근성이 떨어진다는 피드백이 많았습니다.", + "이 부분은 디자인 팀과 논의해서 Q2 초반에 개선 버전을 내놓을 예정입니다.", + "엔지니어링 쪽 상황도 공유드리면 기술 부채 해결을 위한 리팩토링 작업이 지난 분기에 60퍼센트 정도 진행됐습니다.", + "특히 모놀리식이었던 데이터 레이어를 도메인별로 분리하는 작업이 Q1 후반부에 마무리됐고 이 덕에 배포 주기가 확실히 짧아졌습니다.", + "배포 주기는 평균 2주에서 주 단위로 줄어들었고 핫픽스 빈도도 40퍼센트 감소했습니다.", + "다만 테스트 커버리지가 아직 65퍼센트 수준이라 이 부분은 지속적으로 끌어올려야 할 부분입니다.", + "마케팅 팀에서는 Q1에 바이럴 캠페인 두 건을 진행했는데 두 번째 캠페인의 ROAS가 예상보다 크게 나왔습니다.", + "첫 번째 캠페인은 ROAS 1.8 정도로 손익 분기 근처였고 두 번째는 3.4까지 찍었습니다.", + "두 번째 캠페인의 성공 요인은 인플루언서 협업 구조를 기존과 다르게 설계한 점이 컸습니다.", + "성과 지표와 인사이트는 금주 중으로 팀 위키에 상세하게 올려두겠습니다.", + "이제 Q2 방향성 이야기로 넘어가겠습니다. 가장 큰 축은 신규 사용자 리텐션 개선입니다.", + "이걸 위해서는 온보딩 퍼널 전반을 다시 점검해야 할 것 같고 특히 첫 세션 경험이 핵심이라고 봅니다.", + "프로덕트 팀은 온보딩 A/B 테스트를 Q2 중에 최소 세 번 이상 돌리는 걸 목표로 삼았습니다.", + "각 테스트마다 최소 2주의 학습 기간을 두고 통계적 유의성을 확보한 뒤 의사결정을 내릴 예정입니다.", + "두 번째 축은 수익성 개선인데 유료 요금제 구조 자체를 조금 손볼 필요가 있다는 판단입니다.", + "현재 Pro 요금제와 Team 요금제 사이 갭이 너무 커서 중간에 있는 사용자들이 넘어가지 못하고 있어요.", + "이 부분은 비즈니스 팀에서 4월 내로 새 가격 구조 제안을 들고 올 예정입니다.", + "세 번째 축은 엔지니어링 안정성 강화입니다. 테스트 커버리지 80퍼센트 돌파가 목표입니다.", + "또한 관측성 개선을 위해 에러 트래킹 도구를 Sentry에서 내부 솔루션으로 마이그레이션합니다.", + "마이그레이션은 4월 말부터 시작해서 5월 중순까지 완료하는 일정으로 계획되어 있습니다.", + "디자인 팀에서는 디자인 시스템 v2를 Q2 말까지 마무리하는 걸 목표로 하고 있습니다.", + "v2에서는 토큰 기반 구조로 전면 개편되고 다크 모드 대응도 전 컴포넌트에 걸쳐 적용될 예정입니다.", + "자 이제 커뮤니케이션 관련 이야기를 드리고 싶은데요 팀 간 싱크 회의가 너무 많아졌다는 피드백이 있었습니다.", + "그래서 금요일 오후 싱크는 월 1회로 축소하고 나머지는 비동기 문서 공유로 전환할 예정입니다.", + "각 팀 리드분들은 다음 주까지 문서 템플릿과 싱크 격주 전환 일정을 공유해 주시면 감사하겠습니다.", + "질문 있으신 분 혹시 계신가요? 없으시면 오늘 미팅은 여기서 마무리하겠습니다.", + "참여해 주셔서 감사하고 다음 미팅은 2주 후 같은 시간에 진행하겠습니다. 수고하셨습니다." + ] + + static let projectKickoff: [String] = [ + "오늘 모인 건 새로 시작하는 통합 분석 프로젝트 킥오프 미팅입니다. 먼저 프로젝트 배경부터 정리해 볼게요.", + "이 프로젝트가 필요해진 배경은 사용자들이 앱에서 하는 행동을 여러 도구에 분산해서 관리하다 보니 일관된 의사결정이 어려워졌기 때문입니다.", + "현재는 이벤트 트래킹은 Amplitude, 에러는 Sentry, 세션 리플레이는 LogRocket 이렇게 분산되어 있어요.", + "목표는 이 셋을 통합한 내부 대시보드를 만드는 것이고 이번 프로젝트 1차 목표는 이벤트와 에러의 통합 뷰 제공입니다.", + "프로젝트 스코프를 정리하자면 In Scope는 크게 네 덩어리입니다.", + "첫째, 이벤트 수집 파이프라인. 기존 클라이언트 SDK에서 보내던 이벤트를 자체 서버로 수집합니다.", + "둘째, 에러 이벤트 파이프라인. Sentry에서 웹훅으로 받아서 동일한 구조로 저장합니다.", + "셋째, 대시보드 UI. 날짜 필터, 유저 세그먼트 필터 기본 기능 포함.", + "넷째, 알림 규칙 엔진. 특정 조건 만족 시 슬랙으로 알림 보내는 기능.", + "Out of Scope는 세션 리플레이 통합과 실시간 스트리밍입니다. 이건 2차 프로젝트에서 다룰 예정입니다.", + "기술 스택은 백엔드는 Go, 데이터 저장은 ClickHouse, 프론트엔드는 Next.js로 가는 걸로 이미 합의됐습니다.", + "왜 ClickHouse냐고 물으신다면 대용량 시계열 분석 쿼리 성능이 압도적이고 스토리지 효율도 좋기 때문입니다.", + "PoC 단계에서 Postgres와 비교해 봤는데 1억 건 기준 집계 쿼리가 40배 이상 빨랐습니다.", + "팀 구성입니다. 백엔드 세 명, 프론트엔드 두 명, 데이터 엔지니어 한 명, 디자이너 한 명, PM 한 명으로 총 여덟 명 체제입니다.", + "저는 PM 역할로 프로젝트 전반을 조율하고 스프린트 관리, 이해관계자 커뮤니케이션을 담당하겠습니다.", + "기술 리드는 민수 님이 맡아 주시고 아키텍처 결정과 코드 리뷰 프로세스를 책임져 주시기로 했습니다.", + "디자인 리드는 지은 님이고 UI 패턴과 데이터 시각화 컨셉을 주도해 주실 거예요.", + "일정에 대해서 이야기해 볼게요. 전체 기간은 8주로 계획했고 2주씩 네 개 스프린트로 나눴습니다.", + "1스프린트는 수집 파이프라인 POC와 데이터 스키마 확정. 결과물은 실제 데이터가 ClickHouse에 적재되는 것.", + "2스프린트는 수집 파이프라인 프로덕션 수준 구현과 대시보드 기본 UI 쉘 구축입니다.", + "3스프린트는 대시보드 핵심 기능 구현. 이벤트/에러 통합 뷰와 기본 필터가 목표입니다.", + "이 시점에 첫 데모를 내부 이해관계자분들께 보여드릴 예정입니다.", + "4스프린트는 알림 규칙 엔진과 접근 권한 관리, 그리고 성능 튜닝입니다.", + "정식 런칭은 8주 차 마지막 주에 하고 그 다음 주를 안정화 기간으로 잡았습니다.", + "리스크에 대해서도 이야기해 봐야 할 것 같은데요.", + "가장 큰 리스크는 데이터 마이그레이션 시 이벤트 스키마 호환성 이슈입니다.", + "지금 클라이언트에서 보내는 이벤트가 300종이 넘는데 이걸 모두 확인하는 데만 해도 시간이 꽤 걸릴 거예요.", + "두 번째 리스크는 ClickHouse 운영 경험이 팀에 많지 않다는 점입니다.", + "이건 DevOps 팀과 협업해서 운영 러너북을 작성하고 도움을 받을 계획입니다.", + "세 번째 리스크는 디자인 리소스입니다. 지은 님이 다른 프로젝트와 병행이라 3스프린트 전까지 여유가 많지 않아요.", + "그래서 1, 2스프린트에는 기존 디자인 시스템을 최대한 활용하고 커스텀 컴포넌트는 3스프린트에 몰아서 작업할 예정입니다.", + "커뮤니케이션 채널은 슬랙 채널과 매일 15분 스탠드업, 그리고 격주 목요일 이해관계자 싱크로 구성됩니다.", + "문서화는 Notion 프로젝트 페이지에 모든 결정 사항을 기록하고 코드 리뷰 지침은 GitHub 위키에 정리합니다.", + "성공 기준은 세 가지입니다. 첫째, 8주 안에 대시보드 정식 런칭.", + "둘째, 런칭 후 1개월 내 내부 사용자 리텐션 60퍼센트 이상 달성.", + "셋째, 기존 도구 대비 주요 분석 쿼리 응답 시간 절반 이하로 단축.", + "이 세 기준을 만족하면 이 프로젝트는 성공했다고 판단할 수 있겠습니다.", + "예산에 대해서도 간단히 공유드리면 총예산은 8천만원이고 이 중 인프라 비용이 2천만원, 외부 도구 라이선스가 1천만원, 인건비 외 기타가 5천만원입니다.", + "ClickHouse 호스팅 비용이 월 300만원 정도 들어갈 예정이라 3개월치 비용을 먼저 확보해 뒀습니다.", + "다음 액션 아이템을 정리해 볼게요.", + "첫째, 기술 리드님은 이번 주 금요일까지 아키텍처 문서 초안 공유.", + "둘째, 백엔드 팀은 다음 주 월요일까지 이벤트 스키마 초안 작성.", + "셋째, 프론트엔드 팀은 디자이너와 협업해서 대시보드 와이어프레임 1차 완성.", + "넷째, 데이터 엔지니어는 ClickHouse 테스트 환경 구축.", + "다섯째, 저는 이해관계자 리스트를 정리하고 격주 싱크 일정 잡겠습니다.", + "질문 있으신 분 있으실까요.", + "아 민수 님 질문 주셨는데 스키마 호환성 이슈 때문에 레거시 이벤트 한꺼번에 못 들어올 가능성이 있다는 얘기시죠.", + "그 부분은 동의해요. 그래서 1스프린트 말에 스키마 호환성 체크리스트를 만들어서 다음 스프린트에 대응 방안을 결정하는 걸로 하시죠.", + "다른 질문 없으시면 오늘 킥오프는 여기서 마무리하고 각자 액션 아이템 수행 부탁드립니다.", + "다음 미팅은 다음 주 화요일 같은 시간 스탠드업으로 시작하겠습니다. 감사합니다." + ] + + static let oneOnOne: [String] = [ + "네 이번 분기 1on1 시작하겠습니다. 오늘은 커리어 방향성과 최근 업무 만족도 중심으로 이야기해 보려 해요.", + "지난 분기 회고부터 간단히 돌아볼까요. 가장 기억에 남는 작업이 뭐였어요?", + "음성 노트 동기화 리팩토링 작업이 제일 기억에 남습니다. 설계 단계부터 마지막 PR까지 전 과정을 주도할 수 있었거든요.", + "그 작업 결과로 팀 전체 배포 속도도 유의미하게 빨라졌다고 들었어요. 본인이 보기엔 성공 요인이 뭐였다고 생각하세요?", + "큰 요인은 초기에 설계 문서에 충분히 시간을 투자한 점이라고 생각해요. 덕분에 구현 단계에서 재작업이 거의 없었어요.", + "좋은 교훈이네요. 이 경험을 다음 프로젝트에서도 이어가고 싶은 부분이 있다면요?", + "네 특히 사이드 이펙트가 클 것 같은 작업은 반드시 RFC 단계를 거치는 습관을 팀 차원에도 전파하고 싶어요.", + "그 부분 좋네요. 혹시 지난 분기에 어려웠던 점은 어떤 게 있었나요?", + "제일 힘들었던 건 커뮤니케이션 부하였어요. 리팩토링 과정에서 다른 팀과 조율할 일이 많았는데 그 비용이 예상보다 컸습니다.", + "이해돼요. 다음번에 유사한 상황이 오면 어떻게 다르게 접근해 보고 싶으세요?", + "가능하면 주간 싱크를 공식화하고 결정 사항을 문서로 남기는 흐름을 초기부터 잡고 싶어요.", + "좋은 시도 같네요. 이제 커리어 방향 이야기로 넘어가 보면 최근에 성장에 대한 생각이 어떤지 궁금해요.", + "솔직히 말씀드리면 시니어 엔지니어 승진을 본격적으로 준비하고 싶다는 마음이 커졌어요.", + "좋네요. 시니어로 올라가기 위해 본인이 가장 보완해야 한다고 생각하는 영역은 어디라고 보세요?", + "기술적으로는 분산 시스템 설계 경험이 부족하고 리더십 측면에서는 주니어 멘토링 경험을 쌓고 싶어요.", + "그 둘은 다음 분기 과제로 직접 만들어 볼 수 있을 것 같아요. 분산 시스템 쪽은 새로 시작하는 분석 프로젝트에서 리드 역할을 주고 싶고요.", + "감사합니다. 리드 역할이라면 구체적으로 어떤 책임을 주시는 건지 조금 더 상세히 듣고 싶습니다.", + "기술적 의사결정, 아키텍처 문서 오너십, 그리고 후배 두 명 코드 리뷰 책임입니다. 부담이 크지 않을까요?", + "부담은 분명 있지만 성장 기회로 받아들이고 싶어요. 대신 중간 점검 포인트는 자주 두고 진행하고 싶습니다.", + "좋습니다. 격주 1on1에서 진행 상황 체크하고 필요하면 코칭도 드릴게요." + ] + + static let customerMeeting: [String] = [ + "Acme Corp 온보딩 미팅 시작하겠습니다. 오늘은 저희 쪽에서 SSO 연동 스펙을 공유드리고 남은 일정 이슈를 정리하는 시간입니다.", + "먼저 SSO 연동은 OIDC 기반으로 진행되고 Acme 쪽 IdP는 Okta라고 들었는데 맞으실까요.", + "네 맞습니다. 내부적으로 Okta 통합된 지 2년 정도 됐고 대부분 내부 도구가 Okta SSO 로그인을 쓰고 있어요.", + "감사합니다. 저희 쪽 구성은 Authorization Code Flow로 진행하고 ID 토큰에서 email과 groups 클레임을 받아서 처리합니다.", + "groups 클레임은 이미 정의된 그룹 네 개를 매핑할 예정입니다. admin, member, viewer, billing 이렇게요.", + "그룹명 수정이 필요하면 말씀해 주시면 저희 쪽 매핑 테이블에 반영하겠습니다.", + "저희 Okta 쪽에서는 그룹명이 acme-admin, acme-member 이런 식으로 prefix가 붙어 있는데 이 부분 매핑 가능할까요.", + "네 충분히 가능합니다. Admin API를 통해 매핑 규칙 설정할 수 있고 간단한 UI도 제공됩니다.", + "이 매핑 UI 프로토타입을 다음 미팅 전까지 공유드릴 예정이고 거기서 실제 그룹명 매핑을 직접 테스트해 보실 수 있습니다.", + "좋네요 그럼 일정 쪽으로 넘어가 보겠습니다. 계약서에 명시된 온보딩 기간은 6주인데 지금 2주 차입니다.", + "저희 쪽에서 우려되는 부분은 5주 차 예정된 전사 교육 세션입니다. 같은 주에 분기 마감이 있어서 인원 참여가 어려울 수 있습니다.", + "그 부분 이해합니다. 교육 세션을 6주 차로 한 주 미루는 건 가능한데 그러면 정식 런칭 일정도 한 주 밀리게 됩니다.", + "저희 측에서는 런칭이 한 주 밀리는 건 문제없습니다. 그보다 안정적인 도입이 중요해서요.", + "좋습니다. 그럼 교육 세션을 6주 차로 옮기고 정식 런칭은 7주 차 수요일로 재조정하겠습니다.", + "아 그리고 계약 범위에 없었던 추가 요구사항이 하나 있습니다. 감사 로그를 저희 SIEM으로 실시간 전송하는 기능입니다.", + "실시간 전송은 현재 저희 기본 플랜에 포함되지 않은 기능이라 별도 견적이 필요합니다.", + "Syslog나 CEF 포맷 중 어느 쪽을 선호하시나요. 또 전송 프로토콜은 TLS 기반 TCP를 권장드립니다.", + "CEF 포맷으로 부탁드리고 TLS TCP 좋습니다. 견적 부탁드립니다.", + "알겠습니다. 이번 주 금요일까지 견적 문서와 구현 일정 초안을 보내 드리겠습니다.", + "네 그쪽 팀에서 내부 검토 후에 결과 공유드리겠습니다. 오늘 미팅 감사했습니다.", + "저희도 감사합니다. 다음 미팅은 다음 주 수요일 같은 시간 맞으실까요.", + "네 확정입니다. 그때까지 SSO 연동 쪽 진척 상황과 감사 로그 견적 같이 리뷰하는 걸로 하시죠.", + "좋습니다. 그러면 오늘은 여기서 마무리하겠습니다. 감사합니다." + ] + + static let techResearch: [String] = [ + "Apple Intelligence에 대한 기술 조사를 정리해 보려고 합니다. 먼저 전체 구조부터 이해해 볼게요.", + "Apple Intelligence는 크게 온디바이스 Foundation Model과 Private Cloud Compute 두 축으로 구성됩니다.", + "온디바이스 모델은 약 3B 파라미터 수준의 경량 모델로 A17 Pro와 M 시리즈 칩에서 동작합니다.", + "텍스트 요약, 재작성, 이메일 답장 제안 같은 일상적 작업은 대부분 온디바이스에서 처리됩니다.", + "더 복잡한 추론이 필요한 작업은 Private Cloud Compute로 전달되어 서버 모델이 처리합니다.", + "Private Cloud Compute의 핵심 특징은 Apple조차 입력 데이터를 볼 수 없는 구조라는 점입니다.", + "이는 Secure Enclave 기반 증명과 Software Transparency 로그를 통해 보장됩니다.", + "개발자 관점에서 중요한 API는 Writing Tools와 Foundation Models 프레임워크입니다.", + "Writing Tools는 시스템 전역에서 접근 가능한 글쓰기 보조 UI로 iOS 18부터 도입되었습니다.", + "앱에서 별도 설정 없이 텍스트 입력 뷰에서 자동으로 활성화됩니다.", + "커스텀 텍스트 엔진을 쓰는 경우 TextKit 2와 몇 가지 프로토콜 준수가 필요합니다.", + "Foundation Models 프레임워크는 iOS 26에서 소개된 새로운 API입니다.", + "앱에서 직접 온디바이스 LLM에 접근할 수 있게 해주고 구조화된 출력을 받아오기 위한 스키마 지정이 가능합니다.", + "LanguageModelSession 객체가 중심이고 시스템 프롬프트, 사용자 프롬프트, 도구 정의 등을 설정할 수 있습니다.", + "도구 정의는 @Generable 매크로로 스위프트 타입에서 JSON 스키마를 자동 생성해 줍니다.", + "이게 기존 OpenAI Function Calling이나 Anthropic Tool Use와 비슷한 개념이라고 보시면 됩니다.", + "성능 측면에서는 프롬프트 1천 토큰 기준 첫 토큰 지연이 약 150밀리초, 생성 속도는 초당 80토큰 정도로 측정됩니다.", + "다만 이 수치는 A17 Pro 기준이고 이전 세대 칩에서는 2배 이상 느릴 수 있습니다.", + "프라이버시 측면에서는 별도 사용자 동의 없이도 기본 요약이나 재작성 기능 호출이 가능하지만 서드파티 앱이 커스텀 프롬프트로 호출할 때는 Entitlement 설정이 필요합니다.", + "요약 모델의 특징 중 하나는 가이드된 생성에 특화되어 있다는 점입니다.", + "즉 자유 형식 생성보다는 스키마 기반 출력에 강점이 있고 출력 토큰 수가 길어질수록 품질이 떨어지는 경향이 있습니다.", + "이런 특성을 고려하면 앱 내 요약 기능은 섹션별로 나눠서 짧게 여러 번 호출하는 쪽이 품질이 더 좋게 나옵니다.", + "테스트해 본 결과 1천 자 이상 입력을 한 번에 넣기보다는 300자 정도씩 분할해서 처리하는 편이 요약 일관성이 2배 이상 좋았습니다.", + "그리고 한국어 성능은 영어보다 뚜렷하게 떨어지는데 특히 긴 문서에서 컨텍스트 유지가 약합니다.", + "이 부분은 향후 모델 업데이트에서 개선될 것으로 보이지만 현재 시점에서는 후처리 로직이 반드시 필요합니다.", + "서버 모델과의 분기는 AvailableModels API로 체크할 수 있고 기기 성능과 배터리 상태에 따라 자동으로 라우팅됩니다.", + "비용 관점에서는 온디바이스 모델은 무료이고 Private Cloud Compute도 개발자 과금 없이 제공됩니다.", + "다만 호출 빈도에는 시스템 차원의 레이트 리미팅이 걸려 있고 초당 호출 수 제한이 존재합니다.", + "정확한 레이트는 공개되지 않았지만 테스트해 본 결과 초당 10회 넘는 호출은 거부되는 경향이 있습니다.", + "이걸 감안하면 앱 내 사용자 액션 하나당 호출 수는 1~2회로 제한하는 설계가 안전합니다." + ] + + // MARK: - 개인 + + static let weeklyReflection: [String] = [ + "이번 주도 금방 지나갔네. 전반적으로 돌아보면 업무적으로는 만족도가 높았던 한 주였던 것 같아.", + "월요일부터 수요일까지는 프로젝트 집중 모드로 일을 했는데 그동안 미뤘던 리팩토링 작업을 마무리할 수 있었어.", + "특히 화요일에 네 시간 연속으로 디프 없이 집중할 수 있었는데 이게 이번 주 가장 큰 성과였어.", + "반대로 아쉬운 점을 꼽자면 운동 루틴이 흐트러졌다는 거야. 원래 주 4회 목표였는데 두 번밖에 못 갔어.", + "월 수 목 이렇게 가기로 했는데 수요일에 야근이 생기면서 루틴이 깨졌고 그 뒤로 회복하지 못했어.", + "수면 패턴도 조금 불규칙했어. 목요일 밤에 넷플릭스 보다가 새벽 2시에 잤거든.", + "그러고 나니까 금요일 오후에 집중력이 급격히 떨어져서 생산성이 떨어진 걸 체감했어.", + "긍정적인 부분을 몇 개 더 꼽자면 이번 주에 독서를 두 시간 정도 확보했어.", + "아주 작은 습관의 힘이라는 책을 읽고 있는데 특히 환경 설계 부분이 인상 깊었어.", + "운동복을 전날 밤에 미리 꺼내 두는 아주 작은 행동만으로도 실행률이 올라간다는 이야기가 실제로 공감됐어.", + "다음 주는 이걸 적용해서 일요일 밤에 월요일 운동복을 미리 준비해 두려고 해.", + "식단 쪽은 평균적으로 괜찮았어. 외식은 두 번만 했고 나머지는 집밥 위주로 유지했어.", + "다만 수요일 야근 날 치킨을 시켜 먹었는데 그 때문에 다음 날 아침 컨디션이 확실히 무거웠어.", + "앞으로 야근 날이라도 간단한 닭가슴살 도시락 정도는 준비해 두는 게 좋겠다는 생각이 들었어.", + "인간관계 쪽에서는 수요일에 오랜만에 친구와 저녁 식사를 했고 덕분에 기분 전환이 잘 됐어.", + "한동안 연락이 뜸해졌던 친구였는데 먼저 약속을 잡길 잘했다는 생각이 들었어.", + "다음 주 목표를 세워 볼까 한다. 첫 번째는 주 4회 운동 루틴 복구.", + "두 번째는 저녁 10시 전에 스마트폰 내려놓고 책 읽거나 잠자리 준비하기.", + "세 번째는 자기계발 시간 블록 두 시간을 캘린더에 미리 박아 두기.", + "캘린더에 미리 잡아 두지 않으면 결국 다른 일정에 밀린다는 걸 이번 주에 다시 확인했어.", + "전반적으로 이번 주는 6.5/10 정도 주고 싶어. 업무 7, 건강 5, 관계 7, 학습 7 정도로 가중 평균한 느낌." + ] + + static let travelPlanning: [String] = [ + "다음 달 제주도 여행 계획 브레인스토밍 시작. 먼저 일정은 4월 셋째 주 금토일 2박 3일로 확정됐고.", + "항공편은 김포-제주 왕복인데 출발은 금요일 오후 6시 비행기, 귀국은 일요일 저녁 7시 비행기를 고려 중이야.", + "금요일 퇴근하고 바로 공항 가기에는 시간이 빠듯할 수 있으니까 오후 반차를 쓰는 것도 옵션이야.", + "숙소는 서귀포 중심으로 2박 다 한 곳에서 지낼지 아니면 동쪽 서쪽으로 나눠서 잡을지 결정해야 해.", + "한 곳에 머무는 쪽이 짐 옮기는 부담이 없고 렌터카 동선도 더 유연해서 일단 서귀포 쪽으로 기울고 있어.", + "후보 숙소는 세 군데야. 첫째는 서귀포 시내 호텔, 둘째는 안덕면 풀빌라, 셋째는 성산 근처 독채 펜션.", + "풀빌라는 가격이 조금 비싼데 일행이 넷이어서 1인당으로 나누면 호텔이랑 비슷해져.", + "렌터카는 필수야. 중형 SUV로 예약하는 게 4명 짐 싣기에 편할 것 같아.", + "여행자 보험 포함 풀커버 옵션으로 가는 게 마음 편해. 가격 차이가 크지 않거든.", + "일정 짜 볼게. 첫째 날 도착하면 저녁 식사부터 해결하고 숙소 체크인. 가볍게 산책하고 일찍 쉬자.", + "저녁은 서귀포 매일올레시장 근처 흑돼지 집이 후보야. 예약 가능한지 확인 필요.", + "둘째 날은 동쪽 코스. 오전에 성산일출봉 등반, 점심은 성산 근처 해물라면 맛집.", + "오후에는 우도 선착장으로 넘어가서 섬 한 바퀴 돌고 카페 타임. 날씨 좋으면 해녀 체험도 옵션.", + "저녁은 다시 서귀포 쪽으로 내려와서 해산물 저녁. 갈치조림이나 전복 코스 요리 중 고민 중.", + "셋째 날은 서쪽 코스. 오전에 카멜리아 힐 정원 산책, 점심은 한림 근처 이탈리안 레스토랑.", + "오후에는 협재 해수욕장에서 바다 보고 카페 하나 들른 뒤 공항으로 이동.", + "우중 대비 플랜 B가 필요해. 비 오면 박물관이나 카페 투어로 전환하기.", + "박물관 후보는 유리의 성, 테디베어 뮤지엄, 아르떼 뮤지엄 정도. 아르떼는 특히 사진이 잘 나와서 좋아.", + "카페는 원앤온리, 앤트러사이트, 이니스프리 제주하우스가 후보. 이동 동선에 맞춰 선택하면 될 듯.", + "먹을거리는 흑돼지, 갈치조림, 해물탕, 성게국수 이렇게 네 가지는 꼭 먹기로 하자.", + "디저트로는 오메기떡이랑 한라봉 주스 포함. 성읍민속마을 근처 오메기떡 집 잘해.", + "쇼핑 리스트는 한라봉 초콜릿, 오메기떡, 제주 맥주 정도. 공항 면세 말고 현지에서 사자.", + "예산은 1인당 50만원으로 잡았어. 항공료 18만, 숙소 15만, 렌터카 7만, 식비와 기타 10만 정도.", + "예약 데드라인은 다음 주 금요일까지. 항공편과 숙소는 빨리 잡아야 가격 유리해.", + "체크리스트 만들자. 여권은 필요 없지만 신분증 필수. 신용카드 두 장, 현금 10만원.", + "일행별 역할 분담도 좀 나누면 좋겠어. 나는 숙소 예약, 친구 둘은 항공과 렌터카 담당으로.", + "날씨 예보는 이번 주 후반에 한 번 체크하고 우중 플랜 최종 확정하기.", + "준비물 리스트에 우산, 선크림, 모자, 여벌 옷, 수건 포함. 해수욕은 아직 철이 아니지만 혹시 모르니까 수영복도." + ] + + static let workoutRoutine: [String] = [ + "3월부터 시작할 운동 루틴 정리. 주 4회로 가고 분할은 상하 분할로 설정할 거야.", + "월요일은 하체 강도 중심. 스쿼트, 루마니안 데드리프트, 레그 프레스, 레그 컬 순서로.", + "스쿼트는 5x5 프로그램으로 하고 마지막 세트만 중량 올리는 방식으로 점진적 과부하.", + "루마니안 데드리프트는 4x10, 레그 프레스 4x12, 레그 컬 3x15로 마무리.", + "화요일은 상체 민 스 중심. 벤치 프레스, 인클라인 덤벨 프레스, 딥스, 케이블 플라이.", + "벤치 프레스도 5x5 프로그램. 주말마다 중량 2.5kg씩 점진적으로 증가.", + "수요일은 쉬는 날이지만 20분 저강도 유산소는 유지. 출근길 걷기로 대체해도 됨.", + "목요일은 하체 볼륨 중심. 레그 프레스, 런지, 힙 스러스트 중심의 볼륨 훈련.", + "금요일은 상체 풀 중심. 풀업, 바벨 로우, 랫 풀다운, 페이스 풀.", + "풀업은 어시스트 밴드 활용해서 일단 주당 2회씩. 점차 밴드 강도 낮춰 가기.", + "주말은 회복의 시간. 가벼운 걷기나 요가 정도만 하고 본격 운동은 쉬기.", + "식단은 단백질 체중 kg당 1.8g 목표. 현재 체중 72kg이니까 하루 130g 정도.", + "유산소는 운동 후 20분 트레드밀로. 인클라인 낮게 두고 시속 6km 정도 빠른 걷기 강도.", + "주요 지표는 월말마다 체크. 스쿼트 1RM, 벤치 1RM, 체중, 허리둘레 네 가지 측정." + ] + + static let bookNotes: [String] = [ + "아주 작은 습관의 힘 독서 메모 시작. 오늘은 1장부터 3장까지 읽고 핵심만 정리할게.", + "저자가 가장 강조하는 메시지는 매일 1퍼센트 개선이 복리로 쌓여 엄청난 차이를 만든다는 거야.", + "1년간 매일 1퍼센트씩 나아지면 37배 성장, 반대로 매일 1퍼센트씩 나빠지면 거의 0에 수렴한다는 계산이 인상적이야.", + "핵심은 목표보다 시스템에 집중하라는 것. 목표 지향적 사고는 단기적으로 유리하지만 지속 가능성이 떨어져.", + "시스템 지향적 사고는 내가 어떤 사람이 되고 싶은지를 정의하고 매일의 행동을 거기에 맞추는 방식이야.", + "예를 들어 책을 한 권 쓰겠다는 목표 대신 나는 매일 글을 쓰는 사람이라는 정체성에 집중하는 식.", + "정체성 기반 습관 설계의 장점은 작은 승리가 누적되면서 자기 인식이 점점 강화된다는 점이야.", + "습관 루프는 신호 갈망 반응 보상 네 단계로 구성돼 있어. 이 네 단계 각각을 설계할 수 있어.", + "좋은 습관 만들기의 네 가지 법칙은 분명하게 매력적으로 쉽게 만족스럽게야.", + "반대로 나쁜 습관 끊기는 보이지 않게 매력 없게 어렵게 불만족스럽게로 역 적용.", + "환경 설계가 습관의 가장 강력한 지렛대라는 부분이 특히 공감됐어.", + "의지력은 유한하지만 환경은 한 번만 세팅해 두면 계속 효과를 발휘하니까.", + "구체적 적용 예시로 과자를 눈에 안 보이는 곳으로 치우기, 운동복을 현관 옆에 두기 등이 제시됐어.", + "2분 규칙도 흥미로워. 새로운 습관은 2분 안에 끝낼 수 있는 형태로 시작하라는 것.", + "예를 들어 하루 30분 운동이 아니라 일단 운동화 신기까지만을 습관의 최소 단위로 설정.", + "시작이 어렵지 시작하고 나면 이어 가기는 상대적으로 쉬워. 스타트 코스트를 없애는 게 핵심.", + "습관 쌓기 전략도 좋았어. 기존 습관 뒤에 새 습관을 붙이는 방식.", + "예를 들어 매일 아침 커피 내릴 때마다 감사 일기 한 줄 쓰기처럼.", + "기존 습관이 트리거 역할을 하니까 기억하기도 쉽고 실행률이 높아져.", + "측정은 동기 부여의 원천이라는 점도 기록해 둘 만해.", + "습관 트래커 같은 도구로 눈에 보이게 만들면 만족감이 생겨서 지속 가능성이 올라가.", + "다만 측정 지표를 잘못 잡으면 오히려 역효과가 날 수 있다는 경고도 있었어.", + "지표에 집착하다 보면 본질을 놓칠 수 있으니까 주기적으로 지표 자체를 재검토하라는 조언이 붙어 있어.", + "오늘 읽은 부분에서 내게 바로 적용할 세 가지를 뽑아 볼게.", + "첫째 운동복 전날 밤에 꺼내 두기. 환경 설계 적용.", + "둘째 아침 커피 뒤에 책 10분 읽기. 습관 쌓기 적용.", + "셋째 매일 밤 자기 전에 당일 회고 한 줄 쓰기. 2분 규칙 적용." + ] + + // MARK: - 학습 + + static let swiftConcurrencyLecture: [String] = [ + "오늘 강의 주제는 Swift 6의 동시성 모델 딥다이브입니다. 크게 세 축으로 나눠서 진행하겠습니다.", + "첫 번째는 액터 모델의 기본 개념, 두 번째는 Sendable 프로토콜, 세 번째는 격리 경계 설계 패턴입니다.", + "Swift 5.5에서 async await가 도입됐고 5.5에서 actor가 함께 들어왔습니다.", + "Swift 6에서는 데이터 레이스 방지가 컴파일 타임에 강제됩니다. 이게 가장 큰 변화입니다.", + "액터는 내부 상태를 직렬화된 접근으로 보호하는 참조 타입입니다.", + "액터의 메서드는 외부에서 호출할 때 자동으로 비동기가 되고 컨텍스트 전환 비용이 발생합니다.", + "액터 안에서 다른 액터를 호출하면 suspension point가 생기고 제어권이 스케줄러로 넘어갑니다.", + "여기서 주의할 점은 suspension 전후에 액터 상태가 변했을 수 있다는 점입니다.", + "이를 actor reentrancy라고 부르고 이 때문에 원자성 있는 연산은 별도 보호가 필요합니다.", + "Sendable은 동시 실행 환경에서 안전하게 전달 가능한 타입을 마킹하는 프로토콜입니다.", + "값 타입 중 모든 프로퍼티가 Sendable이면 자동으로 Sendable을 만족합니다.", + "참조 타입은 기본적으로 Sendable이 아니고 명시적으로 선언해야 합니다.", + "클래스가 Sendable이 되려면 모든 프로퍼티가 let이면서 Sendable이거나 내부 동기화를 제공해야 합니다.", + "동기화는 일반적으로 lock이나 serial queue로 구현하고 unchecked Sendable로 표시합니다.", + "unchecked는 이름 그대로 컴파일러가 검증을 건너뛰는 것이므로 정말 안전한지 개발자 책임이 따릅니다.", + "MainActor는 메인 스레드에서만 실행을 보장하는 글로벌 액터의 대표 사례입니다.", + "UI 관련 코드는 대부분 MainActor 격리가 필요하고 @MainActor 속성으로 명시합니다.", + "타입 전체를 MainActor로 격리하면 모든 멤버 접근이 메인 스레드로 강제됩니다.", + "특정 메서드만 MainActor로 격리하는 것도 가능한데 이 경우 해당 메서드 호출에만 제약이 적용됩니다.", + "MainActor 격리를 과도하게 쓰면 모든 작업이 메인 스레드로 몰려 UI가 버벅일 수 있습니다.", + "성능에 민감한 계산은 별도 액터나 백그라운드 Task로 분리해야 합니다.", + "Task는 동시성 단위이고 async 함수 호출의 진입점입니다.", + "Task.init으로 만들면 현재 액터 컨텍스트를 상속하고 Task.detached는 분리된 컨텍스트에서 실행됩니다.", + "Task 취소는 명시적 cancel 호출이나 부모 Task 취소 시 자동으로 전파됩니다.", + "하지만 자동 취소라는 표현은 조금 오해의 소지가 있습니다. 실제로는 isCancelled 플래그 설정뿐입니다.", + "실제 중단을 위해서는 Task.checkCancellation 호출이나 Task.isCancelled 체크가 필요합니다.", + "이 책에서 제시하는 규칙은 긴 작업은 주기적으로 체크 포인트를 두라는 것입니다.", + "AsyncStream은 비동기 시퀀스 생성을 쉽게 해주는 도구입니다.", + "continuation 기반 API라서 delegate 콜백이나 Combine 퍼블리셔를 쉽게 변환할 수 있습니다.", + "TaskGroup은 여러 병렬 작업을 구조화된 동시성으로 관리하는 도구입니다.", + "각 자식 Task의 결과를 수집하거나 실패 시 전체 그룹 취소 같은 패턴을 쉽게 구현할 수 있습니다.", + "throwing TaskGroup은 자식 중 하나가 throw하면 다른 자식을 자동으로 취소합니다.", + "다만 이 자동 취소도 앞서 말한 것처럼 플래그 설정일 뿐이라 실제 중단은 자식이 체크해야 합니다.", + "Typed Throws는 Swift 6의 새로운 기능입니다. throws(SpecificError)로 명시할 수 있습니다.", + "기존 throws는 Error 타입을 던졌지만 타입드 스로우는 에러 타입을 구체화합니다.", + "호출하는 쪽에서 catch할 때 어떤 에러가 올 수 있는지가 명확해지고 do-catch의 철저함이 보장됩니다.", + "다만 라이브러리 설계 관점에서 타입드 스로우를 공개 API에 쓸 때는 호환성에 주의해야 합니다.", + "에러 케이스 추가가 소스 호환성 브레이킹 체인지가 될 수 있기 때문입니다.", + "내부 모듈 간 경계에서는 자유롭게 쓸 수 있지만 외부 SDK에서는 보수적으로 접근하세요.", + "마지막으로 동시성 디버깅 팁입니다. Xcode의 Thread Sanitizer와 Main Thread Checker를 켜 두세요.", + "그리고 데이터 레이스가 의심되면 일단 Sendable 경고부터 해결하세요. 대부분 원인이 거기 있습니다.", + "또 하나는 액터 메서드를 @MainActor 격리로 바꿀 때 호출 체인 전체를 확인하라는 것입니다.", + "한 곳만 바꾸면 컴파일러가 호출 체인 전체에 수정을 요구해서 스코프가 예상보다 커집니다.", + "오늘 강의는 여기까지이고 다음 시간에는 실전 예제로 actor 기반 동기화 패턴을 다뤄 보겠습니다." + ] + + static let architectureStudy: [String] = [ + "면접 대비로 iOS 아키텍처 패턴 정리해 보자. 후보 패턴은 MVVM, MVI, TCA, Clean Architecture 네 가지.", + "공통점은 관심사 분리를 통해 테스트 용이성과 유지보수성을 높인다는 점이야.", + "MVVM은 View와 ViewModel로 분리하는 패턴. ViewModel은 View의 상태를 표현하고 비즈니스 로직을 담당.", + "iOS에서는 주로 Combine이나 Swift Concurrency로 데이터 바인딩을 구현해.", + "장점은 학습 곡선이 완만하고 UIKit 친화적이라는 점.", + "단점은 ViewModel이 비대해지기 쉽고 의존성 관리가 복잡해질 수 있어.", + "MVI는 Model View Intent 패턴으로 단방향 데이터 플로우가 특징이야.", + "사용자 액션이 Intent로 표현되고 Intent가 Reducer를 거쳐 새 State가 되고 View가 렌더링되는 구조.", + "이 흐름이 예측 가능하고 디버깅이 쉬워서 큰 규모 앱에서 선호돼.", + "단점은 보일러플레이트가 많고 단순한 화면에서는 오버엔지니어링이 될 수 있어.", + "TCA는 Composable Architecture의 줄임말. Point-Free에서 만든 iOS 전용 프레임워크.", + "MVI 철학 위에 Swift 특화 구조를 올린 것이라고 보면 돼.", + "State, Action, Reducer, Effect 네 가지 개념으로 구성되고 각각 테스트 가능한 순수 함수로 표현.", + "장점은 강력한 테스트 도구와 Dependency Injection 구조 내장.", + "단점은 의존성이 강하고 러닝 커브가 가파름.", + "Clean Architecture는 특정 패턴이 아니라 의존성 규칙을 정의한 아키텍처 철학에 가까워.", + "핵심 규칙은 의존성은 안쪽으로만 향한다는 것.", + "레이어는 보통 Presentation, Domain, Data 세 축으로 나누고 Domain이 가장 중심.", + "Domain은 외부 의존성 없이 순수 비즈니스 로직만 담는 게 원칙이야.", + "레이어 간 통신은 프로토콜 기반 추상화를 통해 이뤄지고 구현체는 외곽 레이어에서 제공.", + "MVVM이나 MVI를 프레임워크 측 아키텍처로 쓰면서 전체 구조는 Clean Architecture로 잡는 게 흔한 조합이야.", + "SwiftUI 시대에 오면서 상태 관리 패턴이 더 중요해졌어.", + "작은 화면은 @State와 @Observable 기반 MVVM 가볍게 가는 게 낫고 복잡한 흐름은 TCA 같은 프레임워크가 유리.", + "면접에서 자주 나오는 질문은 왜 이 아키텍처를 선택했는가야. 트레이드오프를 설명할 수 있어야 해.", + "예를 들어 MVVM을 택했다면 팀 러닝 커브와 기존 코드베이스 호환성을 근거로 들 수 있겠지.", + "또 다른 질문은 의존성 주입 방법이야. 생성자 주입과 서비스 로케이터의 장단점 비교.", + "생성자 주입은 명시적이고 테스트하기 쉽지만 객체 그래프가 크면 보일러플레이트가 늘어나.", + "서비스 로케이터는 편하지만 숨은 의존성이 생기고 테스트가 어려워져.", + "최근에는 Swift Package Manager와 더불어 SwiftInject 같은 경량 DI 라이브러리도 괜찮은 선택지야.", + "다음 질문은 테스트 전략이야. 단위 테스트와 통합 테스트 균형을 어떻게 잡는가.", + "Clean Architecture 구조에서는 Domain 레이어 단위 테스트가 핵심이고 상위 레이어는 통합 테스트로 보완.", + "이 구조에서는 Mock 객체를 Domain 프로토콜 기준으로 만들기 때문에 Mock 관리가 단순해져." + ] + + static let coreDataStudy: [String] = [ + "오늘 Core Data 마이그레이션 실습 기록. 기존 스키마에서 두 가지 변경이 필요해.", + "첫 번째는 User 엔티티에 lastActiveAt 속성 추가.", + "두 번째는 Post 엔티티의 content 속성을 String에서 NSAttributedString 변환 가능 형태로 변경.", + "첫 번째는 lightweight migration으로 가능하고 두 번째는 mapping model이 필요한 heavyweight migration이야.", + "lightweight는 Xcode가 자동으로 해주는 부분이니까 모델 버전 추가하고 현재 버전을 새 버전으로 바꾸기만 하면 돼.", + "NSPersistentContainer 설정에서 shouldMigrateStoreAutomatically와 shouldInferMappingModelAutomatically 둘 다 true로.", + "heavyweight는 Mapping Model 파일을 만들어서 속성 변환 규칙을 명시해야 해.", + "Xcode에서 New File로 Mapping Model을 만들고 Source 버전과 Destination 버전을 지정.", + "각 엔티티별 매핑에서 커스텀 변환이 필요한 속성은 Value Expression으로 변환 로직을 작성해.", + "FUNCTION 키워드로 Objective-C 메서드를 호출할 수 있는데 복잡한 변환은 NSEntityMigrationPolicy 서브클래스로 구현하는 게 깔끔해.", + "NSEntityMigrationPolicy에서 createDestinationInstances 메서드를 오버라이드하면 완전한 제어가 가능해.", + "이 메서드에서 새 인스턴스를 만들고 원하는 대로 속성을 채운 뒤 컨텍스트에 넣으면 돼.", + "실습에서 주의할 점은 마이그레이션 전에 반드시 기존 데이터 백업을 떠 두는 것.", + "실수하면 롤백이 어렵기 때문에 테스트 환경에서 여러 번 돌려 본 뒤 운영 반영해야 해.", + "그리고 큰 데이터셋에서는 마이그레이션 시간이 길어질 수 있어서 UI 차단 방지 처리가 필요해.", + "WWDC 세션에서도 강조하는 부분인데 앱 실행 후 첫 쿼리가 날아올 때까지 마이그레이션이 끝나 있어야 해.", + "이를 위해 AppDelegate의 early 단계에서 NSPersistentContainer를 로드하고 로딩 완료 전에는 메인 화면 진입을 지연시키는 패턴이 일반적이야.", + "추가로 대용량 데이터면 progressive migration 패턴도 고려할 만해.", + "버전 간 변경이 클 때 한 번에 점프하지 않고 중간 버전들을 거쳐 점진적으로 마이그레이션하는 방식.", + "각 단계는 lightweight나 작은 heavyweight로 유지해서 실패 지점을 줄이고 디버깅을 쉽게 해.", + "CloudKit 동기화가 활성화된 스토어는 마이그레이션 규칙이 더 엄격해.", + "제거된 필드나 이름 변경 같은 건 CloudKit 호환성 깨짐 없이 불가능해서 추가 위주로만 스키마를 바꿔야 해.", + "실습 결과 lightweight 쪽은 문제없이 마이그레이션 성공.", + "heavyweight 쪽은 처음에 Value Expression만으로 시도했다가 특정 레코드에서 변환 실패 나서 NSEntityMigrationPolicy 서브클래스로 전환.", + "서브클래스에서는 원본 NSString에서 Markdown을 파싱해서 NSAttributedString 속성으로 변환하는 로직을 구현했어." + ] + + // MARK: - 회의록 + + static let designReview: [String] = [ + "온보딩 화면 4차 개선안 디자인 리뷰 시작하겠습니다. 오늘 주제는 3단계 플로우 재구성과 CTA 개선입니다.", + "먼저 이번 개선안 핵심은 기존 5단계였던 온보딩을 3단계로 압축한 것입니다.", + "5단계에서 이탈률이 가장 높았던 권한 요청 화면 두 개를 가치 제공 화면 뒤로 재배치했습니다.", + "이 변경으로 사용자가 앱 가치를 먼저 체험한 뒤 권한을 요청받게 됩니다.", + "A/B 테스트 가설은 권한 허용률이 12퍼센트에서 20퍼센트로 올라갈 것이라는 점입니다.", + "가설 검증은 출시 후 2주간 양쪽 플로우를 절반씩 노출해서 진행합니다.", + "다음으로 CTA 카피 개선입니다. 시작하기를 첫 노트 만들기로 변경했습니다.", + "구체적인 행동을 암시하는 카피가 일반적인 시작 카피보다 클릭률이 높다는 리서치에 기반했습니다.", + "색상은 기존 파란색에서 브랜드 메인 컬러인 따뜻한 오렌지로 변경.", + "컬러 컨트라스트도 WCAG AA 기준을 넘겼다는 점 확인했고 라이트 다크 모두 테스트 완료.", + "다크 모드 대응 미비 항목이 총 8건 발견됐는데 이 부분 짚어드리고 싶습니다.", + "배경색 조정이 필요한 컴포넌트가 3건, 텍스트 컨트라스트 미달이 2건, 그림자 처리 누락이 3건입니다.", + "이번 스프린트 내에 다 처리하기는 어렵고 다음 스프린트 백로그로 편입하는 걸 제안드립니다.", + "엔지니어링 쪽에서 이 부분 우선순위 합의해 주시면 좋겠습니다.", + "모션 디자인 측면에서는 3단계 전환을 수평 슬라이드에서 크로스페이드로 변경했습니다.", + "크로스페이드가 좀 더 차분한 느낌을 주고 온보딩 맥락에 맞다는 팀 의견이 많았습니다.", + "애니메이션 지속 시간은 280밀리초로 통일했고 이는 다른 화면과도 일관성 있는 값입니다.", + "접근성 관점에서는 VoiceOver 레이블을 모든 인터랙션 요소에 지정했습니다.", + "동적 타입 지원도 기존 3단계에서 5단계까지 확장했고 XXL 사이즈에서도 레이아웃이 깨지지 않습니다.", + "리덕션 모드도 지원해서 시스템 설정에 따라 애니메이션이 자동으로 간소화됩니다.", + "언어별 대응도 고려했습니다. 긴 번역이 발생할 가능성이 있는 독일어와 아랍어 기준으로 레이아웃 테스트 완료.", + "아랍어 RTL 레이아웃에서도 CTA 정렬과 아이콘 미러링 확인했습니다.", + "국가별 법적 문구가 들어가는 권한 요청 화면은 지역별 템플릿으로 분리했고 CMS에서 관리 가능하게 했습니다.", + "마지막으로 검증 계획입니다. 내부 사용성 테스트 두 차례, 베타 그룹 일주일 노출 후 정식 A/B 테스트 진입.", + "내부 테스트 참여자는 10명씩 모집하고 태스크 완료율과 주관 만족도 두 지표를 체크합니다.", + "베타 그룹은 이미 모집된 2천명 대상이고 이벤트 분석으로 온보딩 완료율 비교 예정입니다.", + "질문이나 우려 사항 있으시면 말씀해 주세요.", + "네 PM님 질문 주셨는데 다크 모드 8건을 이번 스프린트에 넣으면 리스크가 얼마나 커지는지에 관한 것이었죠.", + "엔지니어링 쪽에서 추산한 바로는 이번 스프린트 용량의 30퍼센트를 차지할 것으로 예상돼 다른 백로그와 충돌이 불가피합니다.", + "그래서 다음 스프린트 백로그 편입을 권장드립니다.", + "다른 의견 없으시면 오늘 리뷰는 승인 상태로 마무리하겠습니다." + ] + + static let allHands: [String] = [ + "3월 전사 공유 시작하겠습니다. 오늘 주요 안건은 분기 성과 공유, 전략 업데이트, 입사자 소개, 채용 계획입니다.", + "먼저 분기 성과입니다. 매출은 목표 대비 104퍼센트 달성했고 전년 동기 대비 38퍼센트 성장했습니다.", + "신규 고객사 계약은 12건으로 목표 대비 120퍼센트 달성했습니다.", + "다만 유저 리텐션 지표는 목표 대비 92퍼센트로 소폭 미달했고 이 부분은 프로덕트 쪽에서 Q2 집중 영역으로 삼습니다.", + "고객 만족도 점수인 NPS는 48로 업계 평균인 31을 크게 상회했습니다.", + "특히 프리미엄 고객 대상 NPS가 62로 매우 높아 이 세그먼트의 로열티가 강함을 보여줍니다.", + "다음으로 전략 업데이트입니다. 이번 분기 전략 우선순위는 세 가지로 설정되었습니다.", + "첫째 엔터프라이즈 시장 확대. 담당 팀을 현재 5명에서 8명으로 증원합니다.", + "둘째 AI 기반 생산성 기능 출시. 자동 요약에 이어 자동 태깅과 자동 분류 기능이 Q2에 출시됩니다.", + "셋째 글로벌 확장. 일본과 싱가포르 시장 진출을 위한 현지화 프로젝트가 본격 시작됩니다.", + "이 세 전략은 내년 IR을 위한 기반이 되는 부분이니 전사 차원에서 우선순위가 높음을 인지해 주세요.", + "다음으로 신규 입사자 소개입니다. 이번 달 총 8명이 합류했고 각 팀별로 간단히 소개드리겠습니다.", + "엔지니어링 팀은 백엔드 2명, iOS 1명, 웹 프론트 1명이 합류했습니다.", + "디자인 팀은 프로덕트 디자이너 1명이 합류했고 브랜드 경험이 풍부하신 분입니다.", + "세일즈 팀은 엔터프라이즈 AE 2명이 합류했고 모두 SaaS 영업 경험 5년 이상입니다.", + "피플 팀은 HR 비즈 파트너 1명이 합류했습니다.", + "신규 입사자분들 환영합니다. 개별 소개는 이번 주 목요일 환영회에서 직접 진행하겠습니다.", + "다음으로 하반기 채용 계획입니다. 총 채용 규모는 현 인원 대비 20퍼센트 증가입니다.", + "엔지니어링 쪽이 가장 큰 비중을 차지하고 데이터 엔지니어와 플랫폼 엔지니어를 중점 채용합니다.", + "프로덕트 매니저 2명, 디자이너 2명, 세일즈 4명이 추가 예정입니다.", + "추천 채용에 대한 리워드는 기존 100만원에서 150만원으로 인상됩니다.", + "좋은 분들 있으시면 적극적으로 추천 부탁드립니다.", + "사무실 관련 공지사항도 있습니다. 4월 둘째 주에 층 배치가 조금 조정됩니다.", + "엔지니어링 팀이 전층으로 이동하고 세일즈 팀이 현재 엔지니어링 자리로 이동합니다.", + "자세한 일정은 이번 주 내로 피플 팀에서 공지 드릴 예정입니다.", + "복지 관련 업데이트입니다. 자기계발 지원금이 연 80만원에서 100만원으로 인상됩니다.", + "또 웰니스 프로그램 일환으로 월 1회 심리 상담 지원이 추가됐습니다.", + "마지막으로 이번 분기 MVP 발표입니다.", + "엔지니어링 쪽에서는 플랫폼팀 김영희 님. 동기화 엔진 리팩토링 주도로 배포 속도를 두 배 개선하셨습니다.", + "디자인 쪽에서는 박철수 님. 디자인 시스템 v2 기반 작업으로 화면 일관성을 크게 높이셨습니다.", + "세일즈 쪽에서는 이미정 님. 이번 분기 엔터프라이즈 계약 4건을 단독 클로징하셨습니다.", + "MVP 수상자들은 박수로 축하드립니다.", + "질의응답 시간입니다. 질문 있으신 분은 채팅으로 남겨주시면 순서대로 답변드리겠습니다.", + "첫 번째 질문 주셨는데 글로벌 확장 타이밍에 대한 질문이네요.", + "일본은 Q2 말, 싱가포르는 Q3 초를 1차 베타 출시 목표로 하고 있습니다.", + "두 번째 질문은 엔터프라이즈 확대에 따른 기존 고객 지원 부담 증가 우려인데요.", + "이 부분은 커스터머 서포트 팀을 Q2에 3명 증원해서 대응할 계획입니다.", + "추가 질문 없으시면 오늘 전사 공유는 여기서 마무리하겠습니다.", + "다음 전사 공유는 4월 마지막 주 금요일입니다. 감사합니다." + ] + + static let incidentMeeting: [String] = [ + "긴급 인시던트 대응 미팅 시작합니다. 현재 상황부터 공유드립니다.", + "오후 2시 37분부터 앱에서 동기화 실패 오류가 전체 사용자 대상으로 발생하고 있습니다.", + "에러 트래킹 시스템에 동시다발로 올라온 이벤트가 3분 만에 5만 건을 넘어섰습니다.", + "원인은 아직 확정 전이지만 가장 유력한 가설은 2시 30분에 배포된 인증 서비스 롤아웃과 관련된 것으로 보입니다.", + "배포된 변경 사항은 토큰 검증 로직 리팩토링이었고 단위 테스트와 스테이징은 통과한 상태로 프로덕션 롤아웃되었습니다.", + "현재 조치로는 해당 배포를 즉시 롤백 중이며 약 3분 내 완료 예상됩니다.", + "롤백 이후 에러율이 떨어지는지 실시간 모니터링 중이고 대시보드에서 같이 지켜보고 있습니다.", + "고객 지원팀은 이미 상황을 인지했고 상위 티어 고객들에게는 개별 연락을 시작했습니다.", + "퍼블릭 상태 페이지에도 Investigating 상태로 업데이트가 올라갔고 실시간으로 진행 상황이 반영됩니다.", + "모든 팀 리드들은 이 채널에서 벗어나지 마시고 상황 업데이트 받는 즉시 공유 부탁드립니다.", + "롤백이 완료됐습니다. 지금부터 에러율 그래프 다시 체크합니다.", + "2시 47분 기준 신규 에러 발생이 급감하고 있고 평시 수준의 10퍼센트 이하까지 내려왔습니다.", + "아직 기존 세션에서 발생한 에러는 재시도 로직이 돌고 있어서 5분 정도 더 지켜봐야 합니다.", + "지금 상황 요약하면 첫째 롤백 완료 둘째 신규 에러 감소 셋째 기존 세션 재시도 진행 중입니다.", + "공지사항 업데이트도 필요한데 상태 페이지에 Monitoring 단계로 전환하고 고객 지원팀도 같이 업데이트합니다.", + "장애 범위 추산으로는 약 12만 명의 사용자가 동기화 오류를 경험했을 것으로 보이고 완전 실패는 아닌 일시적 오류였습니다.", + "2시 52분 현재 신규 에러 발생이 거의 제로 수준까지 떨어졌습니다.", + "그래프 안정화 확인되는 대로 상태 페이지를 Resolved로 전환하고 공식 포스트모템 프로세스 시작합니다.", + "포스트모템은 이번 주 금요일 오전 11시에 진행 예정이고 회의 기록은 Wiki에 공유합니다.", + "포스트모템 전까지 해야 할 작업 리스트입니다." + ] + } +#endif diff --git a/App/Sources/SceneDelegate.swift b/App/Sources/SceneDelegate.swift new file mode 100644 index 00000000..808d3148 --- /dev/null +++ b/App/Sources/SceneDelegate.swift @@ -0,0 +1,50 @@ +import Core +import Data +import Domain +import Presentation +import UIKit + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + private var appCoordinator: AppCoordinator? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + let appDelegate = UIApplication.shared.delegate as? AppDelegate + + if let dependencyContainer = appDelegate?.dependencyContainer { + appCoordinator = .init( + window: window, + dependencyContainer: dependencyContainer + ) + appCoordinator?.start() + } else { + let error = appDelegate?.initializationError + ?? NSError(domain: "ChaGok", code: -1, userInfo: nil) + showInitializationFailureAlert(on: window, error: error) + } + } +} + +private extension SceneDelegate { + func showInitializationFailureAlert(on window: UIWindow, error: Error) { + let rootViewController = UIViewController() + rootViewController.view.backgroundColor = .systemBackground + window.rootViewController = rootViewController + window.makeKeyAndVisible() + + let message = (error as? LocalizedError)?.errorDescription ?? "알 수 없는 오류가 발생했습니다." + let alertController = UIAlertController( + title: "앱 초기화 실패", + message: message, + preferredStyle: .alert + ) + alertController.addAction(UIAlertAction(title: "확인", style: .default)) + + rootViewController.present(alertController, animated: true) + } +} diff --git a/App/Tests/.gitkeep b/App/Tests/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/App/Tests/AppTests.swift b/App/Tests/AppTests.swift new file mode 100644 index 00000000..8ed76fa0 --- /dev/null +++ b/App/Tests/AppTests.swift @@ -0,0 +1,6 @@ +@testable import App +import XCTest + +final class AppTests: XCTestCase { + func testExample() {} +} diff --git a/ChaGok/.gitignore b/ChaGok/.gitignore deleted file mode 100644 index 24b244f9..00000000 --- a/ChaGok/.gitignore +++ /dev/null @@ -1,70 +0,0 @@ -### macOS ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### Xcode ### -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## User settings -xcuserdata/ - -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) -build/ -DerivedData/ -*.moved-aside -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 - -### Xcode Patch ### -*.xcodeproj/* -!*.xcodeproj/project.pbxproj -!*.xcodeproj/xcshareddata/ -!*.xcworkspace/contents.xcworkspacedata -/*.gcno - -### Projects ### -*.xcodeproj -*.xcworkspace - -### Tuist derived files ### -graph.dot -Derived/ - -### Tuist managed dependencies ### -Tuist/.build \ No newline at end of file diff --git a/ChaGok/.swiftformat b/ChaGok/.swiftformat deleted file mode 100644 index 8d9cba65..00000000 --- a/ChaGok/.swiftformat +++ /dev/null @@ -1,22 +0,0 @@ -# SwiftFormat Configuration - ChaGok -# https://github.com/nicklockwood/SwiftFormat - ---exclude Derived,Tuist,.build,build,*/.build,**/Resources,**/Assets.xcassets,**/Preview Content - ---indent 4 ---maxwidth 120 ---trimwhitespace always ---linebreaks lf ---commas always ---semicolons inline ---header strip ---swiftversion 6.0 - ---allman false ---elseposition same-line ---wraparguments preserve ---wrapcollections preserve ---wrapconditions after-first - ---self remove ---importgrouping testable-bottom diff --git a/ChaGok/.swiftlint.yml b/ChaGok/.swiftlint.yml deleted file mode 100644 index 7930a6ce..00000000 --- a/ChaGok/.swiftlint.yml +++ /dev/null @@ -1,50 +0,0 @@ -# SwiftLint Configuration - ChaGok -# https://github.com/realm/SwiftLint - -disabled_rules: - - trailing_whitespace # SwiftFormat에서 처리 - - trailing_comma # Swift 스타일: 멀티라인에서 trailing comma 허용 (git diff 깔끔) - -opt_in_rules: - - empty_count - - explicit_init - - closure_spacing - - first_where - - force_unwrapping - - implicitly_unwrapped_optional - - modifier_order - - overridden_super_call - - redundant_nil_coalescing - - sorted_first_last - - vertical_parameter_alignment_on_call - -excluded: - - Derived - - Tuist - - .build - - build - - "*.xcodeproj" - - "*.xcworkspace" - - "**/Resources/**" - - "**/Assets.xcassets/**" - - "**/Preview Content/**" - -line_length: - warning: 120 - error: 150 - ignores_comments: true - ignores_urls: true - -file_length: - warning: 500 - error: 800 - -type_body_length: - warning: 300 - error: 400 - -function_body_length: - warning: 50 - error: 80 - -reporter: "xcode" diff --git a/ChaGok/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ChaGok/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 9221b9bb..00000000 --- a/ChaGok/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ChaGok/App/Sources/ChaGokApp.swift b/ChaGok/App/Sources/ChaGokApp.swift deleted file mode 100644 index c03609b5..00000000 --- a/ChaGok/App/Sources/ChaGokApp.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Presentation -import SwiftUI - -@main -struct ChaGokApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/ChaGok/App/Tests/ChaGokTests.swift b/ChaGok/App/Tests/ChaGokTests.swift deleted file mode 100644 index 2d0495ba..00000000 --- a/ChaGok/App/Tests/ChaGokTests.swift +++ /dev/null @@ -1,2 +0,0 @@ -import Testing -@testable import App diff --git a/ChaGok/Core/Sources/Core.swift b/ChaGok/Core/Sources/Core.swift deleted file mode 100644 index 9c7b02e0..00000000 --- a/ChaGok/Core/Sources/Core.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -/// ChaGok Core layer -/// 공통 유틸리티, 확장, 프로토콜 등 다른 레이어에서 공유하는 기반 코드를 담습니다. -public enum ChaGokCore { - // Core 확장 및 유틸 추가 -} diff --git a/ChaGok/Core/Tests/CoreTests.swift b/ChaGok/Core/Tests/CoreTests.swift deleted file mode 100644 index ca9b3e3b..00000000 --- a/ChaGok/Core/Tests/CoreTests.swift +++ /dev/null @@ -1,2 +0,0 @@ -import Testing -@testable import Core diff --git a/ChaGok/Data/Sources/ChaGokData.swift b/ChaGok/Data/Sources/ChaGokData.swift deleted file mode 100644 index cc526ff6..00000000 --- a/ChaGok/Data/Sources/ChaGokData.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Domain -import Foundation - -/// ChaGok Data layer -/// 리포지토리 구현, API/DB 등 데이터 소스 접근을 담당합니다. -public enum ChaGokData { - // Data 레포지토리 구현체 추가 -} diff --git a/ChaGok/Data/Tests/DataTests.swift b/ChaGok/Data/Tests/DataTests.swift deleted file mode 100644 index 4a08a929..00000000 --- a/ChaGok/Data/Tests/DataTests.swift +++ /dev/null @@ -1,2 +0,0 @@ -import Testing -@testable import Data diff --git a/ChaGok/Domain/Sources/Domain.swift b/ChaGok/Domain/Sources/Domain.swift deleted file mode 100644 index c13504f8..00000000 --- a/ChaGok/Domain/Sources/Domain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -/// ChaGok Domain layer -/// 엔티티, 유스케이스, 리포지토리 프로토콜 등 비즈니스 로직의 핵심을 담습니다. -public enum ChaGokDomain { - // Domain 모델 및 유스케이스 추가 -} diff --git a/ChaGok/Domain/Tests/DomainTests.swift b/ChaGok/Domain/Tests/DomainTests.swift deleted file mode 100644 index f5ec02d4..00000000 --- a/ChaGok/Domain/Tests/DomainTests.swift +++ /dev/null @@ -1,2 +0,0 @@ -import Testing -@testable import Domain diff --git a/ChaGok/Presentation/Sources/ContentView.swift b/ChaGok/Presentation/Sources/ContentView.swift deleted file mode 100644 index c7127f0d..00000000 --- a/ChaGok/Presentation/Sources/ContentView.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -public struct ContentView: View { - public init() {} - - public var body: some View { - Text("Hello, World!") - .padding() - } -} diff --git a/ChaGok/Presentation/Tests/PresentationTests.swift b/ChaGok/Presentation/Tests/PresentationTests.swift deleted file mode 100644 index 8f348d54..00000000 --- a/ChaGok/Presentation/Tests/PresentationTests.swift +++ /dev/null @@ -1,2 +0,0 @@ -import Testing -@testable import Presentation diff --git a/ChaGok/Project.swift b/ChaGok/Project.swift deleted file mode 100644 index 90675c59..00000000 --- a/ChaGok/Project.swift +++ /dev/null @@ -1,209 +0,0 @@ -import ProjectDescription - -// MARK: - Project Configuration - -let bundleId = "com.yongms.ChaGok" -let displayName = "차곡" -let version = "1.0.0" -let build = "1" -let iOSVersion = "17.0" -let deploymentTargets: DeploymentTargets = .iOS(iOSVersion) - -let settings: Settings = .settings( - base: [ - "IPHONEOS_DEPLOYMENT_TARGET": SettingValue(stringLiteral: iOSVersion), - "SWIFT_VERSION": "6.0", - "PRODUCT_BUNDLE_DISPLAY_NAME": SettingValue(stringLiteral: displayName), - "MARKETING_VERSION": SettingValue(stringLiteral: version), - "CURRENT_PROJECT_VERSION": SettingValue(stringLiteral: build), - ], - defaultSettings: .recommended -) - -// MARK: - Targets - -let appTarget = ProjectDescription.Target.target( - name: "App", - destinations: .iOS, - product: .app, - bundleId: bundleId, - deploymentTargets: deploymentTargets, - infoPlist: .extendingDefault( - with: [ - "CFBundleDisplayName": Plist.Value(stringLiteral: displayName), - "CFBundleShortVersionString": Plist.Value(stringLiteral: version), - "CFBundleVersion": Plist.Value(stringLiteral: build), - "UILaunchScreen": Plist.Value( - dictionaryLiteral: ( - "UIColorName", Plist.Value(stringLiteral: "") - ), - ("UIImageName", Plist.Value(stringLiteral: "")) - ), - ] - ), - buildableFolders: [ - "App/Sources", - "App/Resources", - ], - scripts: [ - .pre(tool: "swiftlint", arguments: [], name: "SwiftLint", basedOnDependencyAnalysis: false), - .pre( - tool: "swiftformat", - arguments: ["--lint", "."], - name: "SwiftFormat", - basedOnDependencyAnalysis: false - ), - ], - dependencies: [ - .target(name: "Presentation"), - .target(name: "Data"), - ], - settings: settings -) - -let coreTarget = ProjectDescription.Target.target( - name: "Core", - destinations: .iOS, - product: .framework, - bundleId: "\(bundleId).Core", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Core/Sources", - ], - dependencies: [] -) - -let domainTarget = ProjectDescription.Target.target( - name: "Domain", - destinations: .iOS, - product: .framework, - bundleId: "\(bundleId).Domain", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Domain/Sources", - ], - dependencies: [ - .target(name: "Core"), - ] -) - -let dataTarget = ProjectDescription.Target.target( - name: "Data", - destinations: .iOS, - product: .framework, - bundleId: "\(bundleId).Data", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Data/Sources", - ], - dependencies: [ - .target(name: "Domain"), - ] -) - -let presentationTarget = ProjectDescription.Target.target( - name: "Presentation", - destinations: .iOS, - product: .framework, - bundleId: "\(bundleId).Presentation", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Presentation/Sources", - ], - dependencies: [ - .target(name: "Domain"), - .external(name: "ComposableArchitecture"), - ] -) - -let appTestsTarget = ProjectDescription.Target.target( - name: "ChaGokTests", - destinations: .iOS, - product: .unitTests, - bundleId: "\(bundleId)Tests", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "App/Tests", - ], - dependencies: [.target(name: "App")] -) - -let coreTestsTarget = ProjectDescription.Target.target( - name: "CoreTests", - destinations: .iOS, - product: .unitTests, - bundleId: "\(bundleId).CoreTests", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Core/Tests", - ], - dependencies: [.target(name: "Core")] -) - -let domainTestsTarget = ProjectDescription.Target.target( - name: "DomainTests", - destinations: .iOS, - product: .unitTests, - bundleId: "\(bundleId).DomainTests", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Domain/Tests", - ], - dependencies: [.target(name: "Domain")] -) - -let dataTestsTarget = ProjectDescription.Target.target( - name: "DataTests", - destinations: .iOS, - product: .unitTests, - bundleId: "\(bundleId).DataTests", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Data/Tests", - ], - dependencies: [.target(name: "Data")] -) - -let presentationTestsTarget = ProjectDescription.Target.target( - name: "PresentationTests", - destinations: .iOS, - product: .unitTests, - bundleId: "\(bundleId).PresentationTests", - deploymentTargets: deploymentTargets, - infoPlist: .default, - buildableFolders: [ - "Presentation/Tests", - ], - dependencies: [.target(name: "Presentation")] -) - -// MARK: - Project - -let project = Project( - name: "ChaGok", - options: .options( - defaultKnownRegions: ["ko", "en"], - developmentRegion: "ko" - ), - settings: settings, - targets: [ - appTarget, - coreTarget, - domainTarget, - dataTarget, - presentationTarget, - appTestsTarget, - coreTestsTarget, - domainTestsTarget, - dataTestsTarget, - presentationTestsTarget, - ] -) diff --git a/ChaGok/Tuist.swift b/ChaGok/Tuist.swift deleted file mode 100644 index 8c404712..00000000 --- a/ChaGok/Tuist.swift +++ /dev/null @@ -1,3 +0,0 @@ -import ProjectDescription - -let tuist = Tuist(project: .tuist()) diff --git a/ChaGok/Tuist/Package.resolved b/ChaGok/Tuist/Package.resolved deleted file mode 100644 index 6dfb2ea1..00000000 --- a/ChaGok/Tuist/Package.resolved +++ /dev/null @@ -1,132 +0,0 @@ -{ - "originHash" : "ae584bd932a4121328e93e90d4a7af90a0d9ea1581aeb3797ce7da17e5e6b8cc", - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", - "version" : "1.1.0" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", - "version" : "1.0.6" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-composable-architecture", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-composable-architecture", - "state" : { - "revision" : "5b0890fabfd68a2d375d68502bc3f54a8548c494", - "version" : "1.23.1" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "2a2a938798236b8fa0bc57c453ee9de9f9ec3ab0", - "version" : "1.4.1" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { - "revision" : "c79f72b3e67a1eb64f66f76704c22ed6a5c1ed84", - "version" : "1.11.0" - } - }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections", - "state" : { - "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", - "version" : "2.6.0" - } - }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { - "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", - "version" : "2.0.9" - } - }, - { - "identity" : "swift-sharing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-sharing", - "state" : { - "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", - "version" : "2.7.4" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "34e463e98ab8541c604af706c99bed7160f5ec70", - "version" : "1.8.1" - } - } - ], - "version" : 3 -} diff --git a/ChaGok/Tuist/Package.swift b/ChaGok/Tuist/Package.swift deleted file mode 100644 index f193b570..00000000 --- a/ChaGok/Tuist/Package.swift +++ /dev/null @@ -1,20 +0,0 @@ -// swift-tools-version: 6.0 -import PackageDescription - -#if TUIST - import struct ProjectDescription.PackageSettings - - let packageSettings = PackageSettings( - // Customize the product types for specific package product - // Default is .staticFramework - // productTypes: ["Alamofire": .framework,] - productTypes: [:] - ) -#endif - -let package = Package( - name: "ChaGok", - dependencies: [ - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.23.1"), - ] -) diff --git a/Core/Project.swift b/Core/Project.swift new file mode 100644 index 00000000..c4db3b9c --- /dev/null +++ b/Core/Project.swift @@ -0,0 +1,65 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +private let coreScheme = Scheme.scheme( + name: "Core", + shared: true, + buildAction: .buildAction( + targets: [.target("Core")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("CoreTests"), parallelization: .disabled) + ]) +) + +private let coreTestsScheme = Scheme.scheme( + name: "CoreTests", + shared: true, + buildAction: .buildAction( + targets: [.target("CoreTests")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("CoreTests"), parallelization: .disabled) + ]) +) + +private let coreTarget = Target.target( + name: "Core", + destinations: .iOS, + product: .framework, + bundleId: "\(bundleId).Core", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Sources/**/*.swift"], + dependencies: [] +) + +private let coreTestsTarget = Target.target( + name: "CoreTests", + destinations: .iOS, + product: .unitTests, + bundleId: "\(bundleId).CoreTests", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Tests/**/*.swift"], + dependencies: [.target(name: "Core")] +) + +let project = Project( + name: "Core", + options: .options( + defaultKnownRegions: ["ko", "en"], + developmentRegion: "ko" + ), + settings: settings, + targets: [ + coreTarget, + coreTestsTarget + ], + schemes: [ + coreScheme, + coreTestsScheme + ] +) diff --git a/Core/Sources/Extensions/.gitkeep b/Core/Sources/Extensions/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Core/Sources/Extensions/Date+Folder.swift b/Core/Sources/Extensions/Date+Folder.swift new file mode 100644 index 00000000..458c4913 --- /dev/null +++ b/Core/Sources/Extensions/Date+Folder.swift @@ -0,0 +1,18 @@ +import Foundation + +public extension Date { + func trashFolderText(deletedAt: Date?, count: Int) -> String { + var deletedText: String + guard let deletedAt else { + return "" + } + deletedText = Self.relativeDateText(referenceDate: deletedAt, now: self) + return "\(count)개 항목 · \(deletedText) 삭제됨" + } + + func searchFolderText() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + return formatter.string(from: self) + } +} diff --git a/Core/Sources/Extensions/Date+Formatting.swift b/Core/Sources/Extensions/Date+Formatting.swift new file mode 100644 index 00000000..0b72a668 --- /dev/null +++ b/Core/Sources/Extensions/Date+Formatting.swift @@ -0,0 +1,47 @@ +import Foundation + +public extension Date { + var yyyyMMddHHmmssString: String { + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: self) + return String( + format: "%04d%02d%02d%02d%02d%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0, + components.hour ?? 0, + components.minute ?? 0, + components.second ?? 0 + ) + } + + func toString(format: String, localeIdentifier: String = "ko_KR") -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: localeIdentifier) + formatter.timeZone = Calendar.current.timeZone + formatter.dateFormat = format + return formatter.string(from: self) + } + + static func relativeDateText(referenceDate: Date, now: Date) -> String { + let elapsed = now.timeIntervalSince(referenceDate) + if elapsed < 0 { + return referenceDate.toString(format: "M월 d일 a h:mm") + } + + let calendar = Calendar.current + if calendar.isDate(referenceDate, inSameDayAs: now) { + return "오늘" + } + + let startOfReference = calendar.startOfDay(for: referenceDate) + let startOfNow = calendar.startOfDay(for: now) + let components = calendar.dateComponents([.day], from: startOfReference, to: startOfNow) + + if let day = components.day, day > 0, day <= 7 { + return "\(day)일 전" + } + + return referenceDate.toString(format: "yyyy.MM.dd") + } +} diff --git a/Core/Sources/Extensions/Date+VoiceNote.swift b/Core/Sources/Extensions/Date+VoiceNote.swift new file mode 100644 index 00000000..8d5d52e9 --- /dev/null +++ b/Core/Sources/Extensions/Date+VoiceNote.swift @@ -0,0 +1,74 @@ +import Foundation + +// MARK: - VoiceNote 관련 Date 확장 + +public extension Date { + func voiceNoteDay(createdAt: Date, updatedAt: Date, duration: Double) -> String { + let dateText = voiceNoteDateText(createdAt: createdAt, updatedAt: updatedAt) + let durationText = duration.koreanDurationString + + if Int(createdAt.timeIntervalSince1970) != Int(updatedAt.timeIntervalSince1970) { + let updateText = Self.updatedDateText(updatedAt: updatedAt, now: self) + return "\(dateText) (\(updateText)) · \(durationText)" + } + + return "\(dateText) · \(durationText)" + } + + func trashVoiceNoteDay(createdAt: Date, updatedAt: Date, deletedAt: Date?) -> String { + let referenceDate = max(createdAt, updatedAt) + let createdTimeText = referenceDate.toString(format: "a h:mm") + + guard let deletedAt else { + return createdTimeText + } + + let deletedText = Self.relativeDateText(referenceDate: deletedAt, now: self) + return "\(createdTimeText) · \(deletedText) 삭제됨" + } + + func searchVoiceNoteDay(createdAt: Date, updatedAt: Date, duration: Double, folderName: String) -> String { + let frontText = voiceNoteDay(createdAt: createdAt, updatedAt: updatedAt, duration: duration) + return frontText + " · " + folderName + } + + internal func voiceNoteDateText(createdAt: Date, updatedAt: Date) -> String { + let referenceDate = max(createdAt, updatedAt) + return Self.voiceNoteMainDateText(referenceDate: referenceDate, now: self) + } + + private static func voiceNoteMainDateText(referenceDate: Date, now: Date) -> String { + var calendar = Calendar.current + calendar.locale = Locale(identifier: "ko_KR") + + let elapsed = now.timeIntervalSince(referenceDate) + if elapsed >= 0, calendar.isDate(referenceDate, equalTo: now, toGranularity: .day) { + if elapsed < 60 { return "방금 전" } + if elapsed < 3600 { return "\(Int(elapsed / 60))분 전" } + return referenceDate.toString(format: "a h:mm") + } + + let oneYearAgo = calendar.date(byAdding: .year, value: -1, to: now) ?? now.addingTimeInterval(-31_536_000) + if referenceDate < oneYearAgo { + return referenceDate.toString(format: "yyyy.MM.dd") + } + + return referenceDate.toString(format: "M월 d일 a h:mm") + } + + private static func updatedDateText(updatedAt: Date, now: Date) -> String { + var calendar = Calendar.current + calendar.locale = Locale(identifier: "ko_KR") + + if calendar.isDate(updatedAt, equalTo: now, toGranularity: .day) { + return "오늘 수정됨" + } + + let oneYearAgo = calendar.date(byAdding: .year, value: -1, to: now) ?? now.addingTimeInterval(-31_536_000) + if updatedAt < oneYearAgo { + return "\(updatedAt.toString(format: "yyyy.MM.dd")) 수정됨" + } + + return "\(updatedAt.toString(format: "M월 d일")) 수정됨" + } +} diff --git a/Core/Sources/Extensions/TimeInterval+Formatting.swift b/Core/Sources/Extensions/TimeInterval+Formatting.swift new file mode 100644 index 00000000..406cd953 --- /dev/null +++ b/Core/Sources/Extensions/TimeInterval+Formatting.swift @@ -0,0 +1,34 @@ +import Foundation + +public extension TimeInterval { + /// `MM:SS` 또는 `HH:MM:SS` 형식의 문자열로 변환합니다. + var durationString: String { + let duration = Duration.seconds(max(self, 0)) + let pattern: Duration.TimeFormatStyle.Pattern = self >= 3600 ? .hourMinuteSecond : .minuteSecond + return duration.formatted(.time(pattern: pattern)) + } + + var koreanDurationString: String { + let total = Int(self) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let seconds = total % 60 + + var parts: [String] = [] + if hours > 0 { + parts.append("\(hours)시간") + } + if minutes > 0 { + parts.append("\(minutes)분") + } + if seconds > 0 { + parts.append("\(seconds)초") + } + + if parts.isEmpty { + return "0초" + } + + return parts.joined(separator: " ") + } +} diff --git a/Core/Sources/Logger/AppLogger.swift b/Core/Sources/Logger/AppLogger.swift new file mode 100644 index 00000000..ffa8038e --- /dev/null +++ b/Core/Sources/Logger/AppLogger.swift @@ -0,0 +1,42 @@ +import Foundation +import os.log + +public enum AppLogger: AppLoggerProtocol, Sendable { + private static let subsystem = "com.yongms.ChaGokChaGok" + private static let category = "default" + private static let osLog = OSLog(subsystem: subsystem, category: category) + private static let minLevel: LogLevel = { + #if DEBUG + return .debug + #else + return .info + #endif + }() + + public static func log( + _ level: LogLevel, + message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + guard level.rawValue >= minLevel.rawValue else { return } + + let fileName = (file as NSString).lastPathComponent + let logMessage = "\(level.symbol) [\(fileName):\(line) \(function)] \(message)" + let type = level.osLogType + + os_log("%{public}@", log: osLog, type: type, logMessage) + } +} + +private extension LogLevel { + var osLogType: OSLogType { + switch self { + case .debug: .debug + case .info: .info + case .warning: .default + case .error: .error + } + } +} diff --git a/Core/Sources/Logger/AppLoggerProtocol.swift b/Core/Sources/Logger/AppLoggerProtocol.swift new file mode 100644 index 00000000..73a5e286 --- /dev/null +++ b/Core/Sources/Logger/AppLoggerProtocol.swift @@ -0,0 +1,38 @@ +import Foundation + +/// - Parameters: +/// - level: 로그 레벨 +/// - message: 로그 메시지 +/// - file: 파일 경로 +/// - function: 함수 이름 +/// - line: 줄 번호 +public protocol AppLoggerProtocol { + static func log(_ level: LogLevel, message: String, file: String, function: String, line: Int) +} + +/// - Parameters: +/// - message: 로그 메시지 +/// - file: 파일 경로 +/// - function: 함수 이름 +/// - line: 줄 번호 +public extension AppLoggerProtocol { + static func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.debug, message: message, file: file, function: function, line: line) + } + + static func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.info, message: message, file: file, function: function, line: line) + } + + static func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.warning, message: message, file: file, function: function, line: line) + } + + static func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(.error, message: message, file: file, function: function, line: line) + } + + static func error(_ error: Swift.Error, file: String = #file, function: String = #function, line: Int = #line) { + log(.error, message: String(describing: error), file: file, function: function, line: line) + } +} diff --git a/Core/Sources/Logger/LogLevel.swift b/Core/Sources/Logger/LogLevel.swift new file mode 100644 index 00000000..fd112efe --- /dev/null +++ b/Core/Sources/Logger/LogLevel.swift @@ -0,0 +1,20 @@ +import Foundation + +public enum LogLevel: Int { + case debug + case info + case warning + case error + + public var symbol: String { + switch self { + case .debug: "🔍" + case .info: "ℹ️" + case .warning: "⚠️" + case .error: "❌" + } + } +} + +extension LogLevel: CaseIterable {} +extension LogLevel: Sendable {} diff --git a/Core/Tests/Extensions/DateFormattingTests.swift b/Core/Tests/Extensions/DateFormattingTests.swift new file mode 100644 index 00000000..6cc96eaf --- /dev/null +++ b/Core/Tests/Extensions/DateFormattingTests.swift @@ -0,0 +1,262 @@ +@testable import Core +import Foundation +import XCTest + +final class DateFormattingTests: XCTestCase { + private var originalTimeZone: TimeZone! + private let seoulTimeZone = TimeZone(identifier: "Asia/Seoul")! + + override func setUp() { + super.setUp() + originalTimeZone = NSTimeZone.default + NSTimeZone.default = seoulTimeZone + } + + override func tearDown() { + NSTimeZone.default = originalTimeZone + super.tearDown() + } +} + +// MARK: - 상대 시간 + +extension DateFormattingTests { + func test_30초전_음성메모일자문구생성시_방금전으로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 4, 1, 12, 0, 0) + let updatedAt = makeDate(2026, 4, 13, 15, 29, 30) + + // When + let result = now.voiceNoteDay(createdAt: createdAt, updatedAt: updatedAt, duration: 720) + + // Then + XCTAssertEqual(result, "방금 전 (오늘 수정됨) · 12분") + } + + func test_5분전_음성메모일자문구생성시_분전으로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 4, 1, 12, 0, 0) + let updatedAt = makeDate(2026, 4, 13, 15, 25, 0) + + // When + let result = now.voiceNoteDay(createdAt: createdAt, updatedAt: updatedAt, duration: 150) + + // Then + XCTAssertEqual(result, "5분 전 (오늘 수정됨) · 2분 30초") + } + + func test_당일1시간초과_상세날짜문구생성시_오전오후시각으로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 3, 15, 15, 23, 0) + let updatedAt = makeDate(2026, 4, 13, 14, 20, 0) + + // When + let result = now.voiceNoteDateText(createdAt: createdAt, updatedAt: updatedAt) + + // Then + XCTAssertEqual(result, "오후 2:20") + } +} + +// MARK: - 절대 날짜 + +extension DateFormattingTests { + func test_1년이내_음성메모일자문구생성시_월일오전오후형식으로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 3, 15, 15, 23, 0) + + // When + let result = now.voiceNoteDay(createdAt: createdAt, updatedAt: createdAt, duration: 720) + + // Then + XCTAssertEqual(result, "3월 15일 오후 3:23 · 12분") + } + + func test_1년초과_음성메모일자문구생성시_연도포함형식으로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2025, 3, 15, 15, 23, 0) + + // When + let result = now.voiceNoteDay(createdAt: createdAt, updatedAt: createdAt, duration: 720) + + // Then + XCTAssertEqual(result, "2025.03.15 · 12분") + } +} + +// MARK: - 휴지통 삭제 시각 + +extension DateFormattingTests { + func test_휴지통_삭제3일전_타임라인문구생성시_일전삭제로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 4, 13, 15, 23, 0) + let deletedAt = makeDate(2026, 4, 10, 10, 0, 0) + + // When + let result = now.trashVoiceNoteDay(createdAt: createdAt, updatedAt: createdAt, deletedAt: deletedAt) + + // Then + XCTAssertEqual(result, "오후 3:23 · 3일 전 삭제됨") + } + + func test_휴지통_삭제2개월전_타임라인문구생성시_개월전삭제로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 4, 13, 15, 23, 0) + let deletedAt = makeDate(2026, 2, 10, 10, 0, 0) + + // When + let result = now.trashVoiceNoteDay(createdAt: createdAt, updatedAt: createdAt, deletedAt: deletedAt) + + // Then + XCTAssertEqual(result, "오후 3:23 · 2026.02.10 삭제됨") + } + + func test_휴지통_삭제1년초과_타임라인문구생성시_yyyyMMdd삭제로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 4, 13, 15, 23, 0) + let deletedAt = makeDate(2025, 3, 15, 15, 23, 0) + + // When + let result = now.trashVoiceNoteDay(createdAt: createdAt, updatedAt: createdAt, deletedAt: deletedAt) + + // Then + XCTAssertEqual(result, "오후 3:23 · 2025.03.15 삭제됨") + } +} + +// MARK: - 폴더 상대 시각 + +extension DateFormattingTests { + func test_폴더_3개항목_1개월전_문구생성시_요청형식으로표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let deletedAt = makeDate(2026, 3, 10, 10, 0, 0) + + // When + let result = now.trashFolderText(deletedAt: deletedAt, count: 3) + + // Then + XCTAssertEqual(result, "3개 항목 · 2026.03.10 삭제됨") + } +} + +// MARK: - 검색 결과 시각 + +extension DateFormattingTests { + func test_검색결과_폴더날짜생성시_yyyyMMdd형식으로표시된다() { + // Given + let date = makeDate(2026, 4, 13, 15, 30, 0) + + // When + let result = date.searchFolderText() + + // Then + XCTAssertEqual(result, "2026.04.13") + } + + func test_검색결과_음성메모생성시_폴더명이포함되어표시된다() { + // Given + let now = makeDate(2026, 4, 13, 15, 30, 0) + let createdAt = makeDate(2026, 4, 13, 15, 20, 0) + + // When + let result = now.searchVoiceNoteDay( + createdAt: createdAt, + updatedAt: createdAt, + duration: 125, + folderName: "아이디어" + ) + + // Then + XCTAssertEqual(result, "10분 전 · 2분 5초 · 아이디어") + } +} + +// MARK: - 문자열 변환 + +extension DateFormattingTests { + func test_yyyyMMddHHmmssString_호출시_지정된형식으로반환된다() { + // Given + let date = makeDate(2026, 4, 13, 15, 30, 45) + + // When + let result = date.yyyyMMddHHmmssString + + // Then + XCTAssertEqual(result, "20260413153045") + } + + func test_toString_호출시_지정된포맷의문자열을반환한다() { + // Given + let date = makeDate(2026, 4, 13, 15, 30, 0) + + // When + let result = date.toString(format: "yyyy-MM-dd HH:mm") + + // Then + XCTAssertEqual(result, "2026-04-13 15:30") + } +} + +// MARK: - 상대 시간 임계값 테스트 + +extension DateFormattingTests { + func test_상대시간_당일_경계값테스트() { + let now = makeDate(2026, 4, 13, 15, 30, 0) + XCTAssertEqual(Date.relativeDateText(referenceDate: now.addingTimeInterval(-60), now: now), "오늘") + XCTAssertEqual(Date.relativeDateText(referenceDate: makeDate(2026, 4, 13, 0, 1, 0), now: now), "오늘") + } + + func test_상대시간_7일이내_경계값테스트() { + let now = makeDate(2026, 4, 13, 15, 30, 0) + let yesterday = makeDate(2026, 4, 12, 23, 59, 59) + let sevenDaysAgo = makeDate(2026, 4, 6, 0, 1, 0) + + XCTAssertEqual(Date.relativeDateText(referenceDate: yesterday, now: now), "1일 전") + XCTAssertEqual(Date.relativeDateText(referenceDate: sevenDaysAgo, now: now), "7일 전") + } + + func test_상대시간_7일초과_경계값테스트() { + let now = makeDate(2026, 4, 13, 15, 30, 0) + let eightDaysAgo = makeDate(2026, 4, 5, 23, 59, 59) + + XCTAssertEqual(Date.relativeDateText(referenceDate: eightDaysAgo, now: now), "2026.04.05") + } +} + +// MARK: - Helpers + +private extension DateFormattingTests { + func makeDate( + _ year: Int, + _ month: Int, + _ day: Int, + _ hour: Int, + _ minute: Int, + _ second: Int + ) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = seoulTimeZone + + let components = DateComponents( + calendar: calendar, + timeZone: seoulTimeZone, + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second + ) + + return try! XCTUnwrap(calendar.date(from: components)) + } +} diff --git a/Core/Tests/Logger/AppLoggerProtocolTests.swift b/Core/Tests/Logger/AppLoggerProtocolTests.swift new file mode 100644 index 00000000..f0cca19f --- /dev/null +++ b/Core/Tests/Logger/AppLoggerProtocolTests.swift @@ -0,0 +1,152 @@ +@testable import Core +import Foundation +import XCTest + +final class AppLoggerProtocolTests: XCTestCase {} + +// MARK: - 성공 케이스 + +extension AppLoggerProtocolTests { + func test_debug메서드_로그호출시_debug레벨로정확히기록된다() { + MockLogger.reset() + + // Given + let message = "테스트 메시지" + + // When + MockLogger.debug(message) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].level, .debug) + XCTAssertEqual(MockLogger.recordedLogs[0].message, message) + } + + func test_info메서드_로그호출시_info레벨로정확히기록된다() { + MockLogger.reset() + + // Given + let message = "정보 메시지" + + // When + MockLogger.info(message) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].level, .info) + XCTAssertEqual(MockLogger.recordedLogs[0].message, message) + } + + func test_warning메서드_로그호출시_warning레벨로정확히기록된다() { + MockLogger.reset() + + // Given + let message = "경고 메시지" + + // When + MockLogger.warning(message) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].level, .warning) + XCTAssertEqual(MockLogger.recordedLogs[0].message, message) + } + + func test_errorString메서드_로그호출시_error레벨로정확히기록된다() { + MockLogger.reset() + + // Given + let message = "에러 메시지" + + // When + MockLogger.error(message) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].level, .error) + XCTAssertEqual(MockLogger.recordedLogs[0].message, message) + } + + func test_errorError객체_로그호출시_Error의문자열설명이정확히기록된다() { + MockLogger.reset() + + // Given + struct TestError: Error {} + let error = TestError() + + // When + MockLogger.error(error) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].level, .error) + XCTAssertEqual(MockLogger.recordedLogs[0].message, String(describing: error)) + } + + func test_여러로그_연속호출시_호출한순서대로정확히기록된다() { + MockLogger.reset() + + // Given + let messages = ["1", "2", "3", "4"] + let levels: [LogLevel] = [.debug, .info, .warning, .error] + + // When + MockLogger.debug(messages[0]) + MockLogger.info(messages[1]) + MockLogger.warning(messages[2]) + MockLogger.error(messages[3]) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 4) + for (index, level) in levels.enumerated() { + XCTAssertEqual(MockLogger.recordedLogs[index].level, level) + XCTAssertEqual(MockLogger.recordedLogs[index].message, messages[index]) + } + } + + func test_빈메시지_로그호출시_정상적으로기록된다() { + MockLogger.reset() + + // Given + let emptyMessage = "" + + // When + MockLogger.info(emptyMessage) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].message, emptyMessage) + } + + func test_특수문자포함메시지_로그호출시_정상적으로기록된다() { + MockLogger.reset() + + // Given + let specialMessage = "이모지 🔥 유니코드 日本語 \n 줄바꿈" + + // When + MockLogger.info(specialMessage) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].message, specialMessage) + } + + func test_LocalizedError미준수객체_로그호출시_기본문자열설명으로기록된다() { + MockLogger.reset() + + // Given + struct CustomError: Error { + let code: Int + } + let error = CustomError(code: 42) + + // When + MockLogger.error(error) + + // Then + XCTAssertEqual(MockLogger.recordedLogs.count, 1) + XCTAssertEqual(MockLogger.recordedLogs[0].level, .error) + XCTAssertFalse(MockLogger.recordedLogs[0].message.isEmpty) + } +} diff --git a/Core/Tests/Logger/AppLoggerTests.swift b/Core/Tests/Logger/AppLoggerTests.swift new file mode 100644 index 00000000..09957598 --- /dev/null +++ b/Core/Tests/Logger/AppLoggerTests.swift @@ -0,0 +1,123 @@ +@testable import Core +import Foundation +import XCTest + +final class AppLoggerTests: XCTestCase {} + +// MARK: - 성공 케이스 + +extension AppLoggerTests { + func test_정상적인메시지_로그호출시_크래시없이동작한다() { + // Given + let message = "테스트" + let file = "Test.swift" + let function = "test()" + let line = 1 + + // When & Then + AppLogger.log(.info, message: message, file: file, function: function, line: line) + } + + func test_다양한로그레벨_편의메서드호출시_크래시없이동작한다() { + // Given + let message = "log message" + + // When & Then + AppLogger.debug(message) + AppLogger.info(message) + AppLogger.warning(message) + AppLogger.error(message) + } + + func test_빈메시지_로그호출시_정상적으로처리된다() { + // Given + let emptyMessage = "" + + // When & Then + AppLogger.info(emptyMessage) + } + + func test_매우긴메시지_로그호출시_성능저하나크래시없이처리된다() { + // Given + let veryLongMessage = String(repeating: "a", count: 100_000) + + // When & Then + AppLogger.info(veryLongMessage) + } + + func test_특수문자및이모지포함메시지_로그호출시_정상적으로출력된다() { + // Given + let specialMessage = "이모지 🔥 유니코드 日本語 \n 줄바꿈" + + // When & Then + AppLogger.info(specialMessage) + } + + func test_os_log포맷포함메시지_로그호출시_포맷에러없이정상처리된다() { + // Given + let formatMessage = "%d %{public}@ {" + + // When & Then + AppLogger.info(formatMessage) + } + + func test_여러스레드에서동시호출_로그호출시_스레드세이프하게동작한다() { + // Given + let expectation = expectation(description: "concurrent logs") + let totalCount = 100 + expectation.expectedFulfillmentCount = totalCount + + // When + for index in 0 ..< totalCount { + DispatchQueue.global().async { + AppLogger.info("concurrent \(index)") + expectation.fulfill() + } + } + + // Then + wait(for: [expectation], timeout: 5) + } + + func test_백그라운드스레드_로그호출시_정상적으로동작한다() { + // Given + let expectation = expectation(description: "background") + + // When + DispatchQueue.global().async { + AppLogger.info("background thread") + expectation.fulfill() + } + + // Then + wait(for: [expectation], timeout: 2) + } +} + +// MARK: - 경계 값 케이스 + +extension AppLoggerTests { + func test_빈파일경로_로그호출시_크래시없이동작한다() { + // Given + let file = "" + + // When & Then + AppLogger.log(.info, message: "test", file: file, function: "test()", line: 1) + } + + func test_빈함수명_로그호출시_크래시없이동작한다() { + // Given + let function = "" + + // When & Then + AppLogger.log(.info, message: "test", file: "Test.swift", function: function, line: 1) + } + + func test_라인번호0_로그호출시_크래시없이동작한다() { + // Given + let line = 0 + + // When & Then + AppLogger.log(.info, message: "test", file: "Test.swift", function: "test()", line: line) + } +} diff --git a/Core/Tests/Logger/LogLevelTests.swift b/Core/Tests/Logger/LogLevelTests.swift new file mode 100644 index 00000000..57ceab85 --- /dev/null +++ b/Core/Tests/Logger/LogLevelTests.swift @@ -0,0 +1,44 @@ +@testable import Core +import Foundation +import XCTest + +final class LogLevelTests: XCTestCase {} + +// MARK: - 성공 케이스 + +extension LogLevelTests { + func test_로그레벨정의_rawValue확인시_기대하는순서로정의되어있다() { + // Given & When & Then + XCTAssertEqual(LogLevel.debug.rawValue, 0) + XCTAssertEqual(LogLevel.info.rawValue, 1) + XCTAssertEqual(LogLevel.warning.rawValue, 2) + XCTAssertEqual(LogLevel.error.rawValue, 3) + } + + func test_로그레벨정의_레벨비교시_심각도순서가올바르다() { + // Given & When & Then + XCTAssertLessThan(LogLevel.debug.rawValue, LogLevel.info.rawValue) + XCTAssertLessThan(LogLevel.info.rawValue, LogLevel.warning.rawValue) + XCTAssertLessThan(LogLevel.warning.rawValue, LogLevel.error.rawValue) + } + + func test_로그레벨정의_symbol확인시_모든레벨에심볼이존재한다() { + // Given & When & Then + XCTAssertFalse(LogLevel.debug.symbol.isEmpty) + XCTAssertFalse(LogLevel.info.symbol.isEmpty) + XCTAssertFalse(LogLevel.warning.symbol.isEmpty) + XCTAssertFalse(LogLevel.error.symbol.isEmpty) + } + + func test_CaseIterable준수_모든케이스조회시_4가지레벨이모두포함되어있다() { + // Given + let expectedLevels: [LogLevel] = [.debug, .info, .warning, .error] + + // When + let allLevels = LogLevel.allCases + + // Then + XCTAssertEqual(allLevels.count, 4) + XCTAssertEqual(allLevels, expectedLevels) + } +} diff --git a/Core/Tests/Logger/MockLogger.swift b/Core/Tests/Logger/MockLogger.swift new file mode 100644 index 00000000..73468b9e --- /dev/null +++ b/Core/Tests/Logger/MockLogger.swift @@ -0,0 +1,14 @@ +@testable import Core +import Foundation + +enum MockLogger: AppLoggerProtocol { + nonisolated(unsafe) static var recordedLogs: [(level: LogLevel, message: String)] = [] + + static func log(_ level: LogLevel, message: String, file: String, function: String, line: Int) { + recordedLogs.append((level, message)) + } + + static func reset() { + recordedLogs = [] + } +} diff --git a/Data/Project.swift b/Data/Project.swift new file mode 100644 index 00000000..89e62ecd --- /dev/null +++ b/Data/Project.swift @@ -0,0 +1,47 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +private let dataScheme = Scheme.scheme( + name: "Data", + shared: true, + buildAction: .buildAction( + targets: [.target("Data")], + findImplicitDependencies: true + ) +) + +private let dataTarget = Target.target( + name: "Data", + destinations: .iOS, + product: .framework, + bundleId: "\(bundleId).Data", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Sources/**/*.swift"], + resources: ["Resources/**"], + dependencies: [ + .project(target: "Core", path: "../Core"), + .project(target: "Domain", path: "../Domain"), + .external(name: "ArgmaxOSS"), + .external(name: "MLXLLM"), + .external(name: "MLXLMCommon"), + .external(name: "MLXHuggingFace"), + .external(name: "HuggingFace"), + .external(name: "Tokenizers") + ] +) + +let project = Project( + name: "Data", + options: .options( + defaultKnownRegions: ["ko", "en"], + developmentRegion: "ko" + ), + settings: settings, + targets: [ + dataTarget + ], + schemes: [ + dataScheme + ] +) diff --git a/Data/Resources/ChaGok.xcdatamodeld/ChaGok.xcdatamodel/contents b/Data/Resources/ChaGok.xcdatamodeld/ChaGok.xcdatamodel/contents new file mode 100644 index 00000000..83d711a9 --- /dev/null +++ b/Data/Resources/ChaGok.xcdatamodeld/ChaGok.xcdatamodel/contents @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Data/Sources/Infrastructure/CoreData/CoreDataLocalDataBase.swift b/Data/Sources/Infrastructure/CoreData/CoreDataLocalDataBase.swift new file mode 100644 index 00000000..173640ff --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/CoreDataLocalDataBase.swift @@ -0,0 +1,43 @@ +import Core +import CoreData + +/// Core Data NSPersistentContainer 셋업 wrapper. +/// CRUD/observe 같은 데이터 접근 로직은 각 Repository에서 직접 처리합니다. +@MainActor +public final class CoreDataLocalDataBase { + private static let modelName: String = "ChaGok" + + public let container: NSPersistentContainer + + /// Core Data 스토리지를 초기화하고 모델 파일을 로드합니다. + /// - Parameter inMemory: 메모리 상에서만 동작할지 여부 (테스트 용도) + public init(inMemory: Bool = false) throws(CoreDataStorageError) { + let bundle = Bundle(for: CoreDataLocalDataBase.self) + guard let model = NSManagedObjectModel.mergedModel(from: [bundle]) else { + throw .resourceNotFound + } + + let newContainer = NSPersistentContainer( + name: CoreDataLocalDataBase.modelName, + managedObjectModel: model + ) + + if inMemory { + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + newContainer.persistentStoreDescriptions = [description] + } + + var initializationError: Error? + newContainer.loadPersistentStores { _, error in + initializationError = error + } + + if let initializationError { + AppLogger.error(initializationError) + throw .initializeFailed + } + + container = newContainer + } +} diff --git a/Data/Sources/Infrastructure/CoreData/Entities/FolderEntity+CoreDataClass.swift b/Data/Sources/Infrastructure/CoreData/Entities/FolderEntity+CoreDataClass.swift new file mode 100644 index 00000000..101f914c --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/Entities/FolderEntity+CoreDataClass.swift @@ -0,0 +1,61 @@ +import CoreData +import Domain + +@objc(FolderEntity) +public final class FolderEntity: NSManagedObject { + @NSManaged + public var id: UUID + + @NSManaged + public var name: String + + @NSManaged + public var createdAt: Date + + @NSManaged + public var kindRaw: String + + @NSManaged + public var deletedAt: Date? + + @NSManaged + public var parentID: UUID? + + @NSManaged + public var voiceNotes: NSSet? +} + +extension FolderEntity { + static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Folder") + } + + convenience init(model: Folder, context: NSManagedObjectContext) { + self.init(context: context) + update(from: model) + } + + func update(from model: Folder) { + id = model.id + name = model.name + createdAt = model.createdAt + kindRaw = model.kind.rawValue + deletedAt = model.deletedAt + parentID = model.parentID + } + + func toModel() -> Folder { + let aliveNoteIDs = (voiceNotes as? Set)? + .filter { $0.deletedAt == nil } + .map(\.id) ?? [] + return Folder( + id: id, + name: name, + createdAt: createdAt, + voiceNoteIDs: aliveNoteIDs, + kind: FolderKind(rawValue: kindRaw) ?? .custom, + deletedAt: deletedAt, + parentID: parentID + ) + } +} diff --git a/Data/Sources/Infrastructure/CoreData/Entities/KeywordEntity+CoreDataClass.swift b/Data/Sources/Infrastructure/CoreData/Entities/KeywordEntity+CoreDataClass.swift new file mode 100644 index 00000000..ef4187bb --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/Entities/KeywordEntity+CoreDataClass.swift @@ -0,0 +1,38 @@ +import CoreData +import Domain + +@objc(KeywordEntity) +public final class KeywordEntity: NSManagedObject { + @NSManaged + public var id: UUID + + @NSManaged + public var word: String + + @NSManaged + public var voiceNote: VoiceNoteEntity +} + +extension KeywordEntity { + static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Keyword") + } + + convenience init(model: Keyword, context: NSManagedObjectContext) { + self.init(context: context) + update(from: model) + } + + func update(from model: Keyword) { + id = model.id + word = model.word + } + + func toModel() -> Keyword { + Keyword( + id: id, + noteID: voiceNote.id, + word: word + ) + } +} diff --git a/Data/Sources/Infrastructure/CoreData/Entities/SummaryEntity+CoreDataClass.swift b/Data/Sources/Infrastructure/CoreData/Entities/SummaryEntity+CoreDataClass.swift new file mode 100644 index 00000000..7bc5100b --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/Entities/SummaryEntity+CoreDataClass.swift @@ -0,0 +1,42 @@ +import CoreData +import Domain + +@objc(SummaryEntity) +public final class SummaryEntity: NSManagedObject { + @NSManaged + public var id: UUID + + @NSManaged + public var text: String + + @NSManaged + public var createdAt: Date + + @NSManaged + public var voiceNote: VoiceNoteEntity +} + +extension SummaryEntity { + static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Summary") + } + + convenience init(model: Summary, context: NSManagedObjectContext) { + self.init(context: context) + update(from: model) + } + + func update(from model: Summary) { + id = model.id + text = model.text + createdAt = model.createdAt + } + + func toModel() -> Summary { + Summary( + id: id, + createdAt: createdAt, + text: text + ) + } +} diff --git a/Data/Sources/Infrastructure/CoreData/Entities/TranscriptEntity+CoreDataClass.swift b/Data/Sources/Infrastructure/CoreData/Entities/TranscriptEntity+CoreDataClass.swift new file mode 100644 index 00000000..a7d69aec --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/Entities/TranscriptEntity+CoreDataClass.swift @@ -0,0 +1,48 @@ +import CoreData +import Domain + +@objc(TranscriptEntity) +public final class TranscriptEntity: NSManagedObject { + @NSManaged + public var id: UUID + + @NSManaged + public var createdAt: Date + + @NSManaged + public var updatedAt: Date? + + @NSManaged + public var sectionsData: Data + + @NSManaged + public var voiceNote: VoiceNoteEntity +} + +extension TranscriptEntity { + static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "Transcript") + } + + convenience init(model: Transcript, context: NSManagedObjectContext) { + self.init(context: context) + update(from: model) + } + + func update(from model: Transcript) { + id = model.id + createdAt = model.createdAt + updatedAt = model.updatedAt + sectionsData = (try? JSONEncoder().encode(model.sections)) ?? Data() + } + + func toModel() -> Transcript { + let sections = (try? JSONDecoder().decode([TranscriptSection].self, from: sectionsData)) ?? [] + return Transcript( + id: id, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + sections: sections + ) + } +} diff --git a/Data/Sources/Infrastructure/CoreData/Entities/VoiceNoteEntity+CoreDataClass.swift b/Data/Sources/Infrastructure/CoreData/Entities/VoiceNoteEntity+CoreDataClass.swift new file mode 100644 index 00000000..3acbec9a --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/Entities/VoiceNoteEntity+CoreDataClass.swift @@ -0,0 +1,86 @@ +import CoreData +import Domain + +@objc(VoiceNoteEntity) +public final class VoiceNoteEntity: NSManagedObject { + @NSManaged + public var id: UUID + + @NSManaged + public var title: String + + @NSManaged + public var createdAt: Date + + @NSManaged + public var updatedAt: Date + + @NSManaged + public var deletedAt: Date? + + @NSManaged + public var originalFolderID: UUID? + + @NSManaged + public var analysisStateRaw: String + + @NSManaged + public var folder: FolderEntity + + @NSManaged + public var voiceRecord: VoiceRecordEntity + + @NSManaged + public var keywords: NSSet? + + @NSManaged + public var transcript: TranscriptEntity? + + @NSManaged + public var summary: SummaryEntity? +} + +extension VoiceNoteEntity { + static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "VoiceNote") + } + + /// 도메인 모델로부터 새 entity를 생성합니다. (scalar attribute만 set, 관계는 caller가 처리) + convenience init(model: VoiceNote, context: NSManagedObjectContext) { + self.init(context: context) + update(from: model) + } + + /// scalar attribute만 도메인 모델 값으로 업데이트합니다. + /// 관계(folder/voiceRecord/keywords/transcript/summary)는 호출자가 직접 set합니다. + func update(from model: VoiceNote) { + id = model.id + title = model.title + createdAt = model.createdAt + updatedAt = model.updatedAt + deletedAt = model.deletedAt + originalFolderID = model.originalFolderID + analysisStateRaw = model.analysisState.rawValue + } + + /// entity를 도메인 모델로 변환합니다. 관계는 이미 attached됐다고 가정합니다. + func toModel() -> VoiceNote { + let keywordModels = (keywords?.allObjects as? [KeywordEntity])?.map { $0.toModel() } ?? [] + let state = AnalysisState(rawValue: analysisStateRaw) ?? .pending + + return VoiceNote( + id: id, + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + folderID: folder.id, + voiceRecord: voiceRecord.toModel(), + keywords: keywordModels, + transcript: transcript?.toModel(), + summary: summary?.toModel(), + deletedAt: deletedAt, + originalFolderID: originalFolderID, + analysisState: state + ) + } +} diff --git a/Data/Sources/Infrastructure/CoreData/Entities/VoiceRecordEntity+CoreDataClass.swift b/Data/Sources/Infrastructure/CoreData/Entities/VoiceRecordEntity+CoreDataClass.swift new file mode 100644 index 00000000..698a60eb --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/Entities/VoiceRecordEntity+CoreDataClass.swift @@ -0,0 +1,47 @@ +import CoreData +import Domain + +@objc(VoiceRecordEntity) +public final class VoiceRecordEntity: NSManagedObject { + @NSManaged + public var id: UUID + + @NSManaged + public var audioFilePath: String + + @NSManaged + public var createdAt: Date + + @NSManaged + public var duration: Double + + @NSManaged + public var voiceNote: VoiceNoteEntity +} + +extension VoiceRecordEntity { + static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "VoiceRecord") + } + + convenience init(model: VoiceRecord, context: NSManagedObjectContext) { + self.init(context: context) + update(from: model) + } + + func update(from model: VoiceRecord) { + id = model.id + audioFilePath = model.audioFilePath + duration = model.duration + createdAt = model.createdAt + } + + func toModel() -> VoiceRecord { + VoiceRecord( + id: id, + createdAt: createdAt, + audioFilePath: audioFilePath, + duration: duration + ) + } +} diff --git a/Data/Sources/Infrastructure/CoreData/Error/CoreDataStorageError.swift b/Data/Sources/Infrastructure/CoreData/Error/CoreDataStorageError.swift new file mode 100644 index 00000000..645c222c --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/Error/CoreDataStorageError.swift @@ -0,0 +1,18 @@ +import Foundation + +/// CoreDataLocalDataBase 초기화 단계에서 발생할 수 있는 에러. +public enum CoreDataStorageError: LocalizedError, Sendable { + /// 모델 파일(.momd) 등 필수 리소스를 찾을 수 없음 + case resourceNotFound + /// 영구 저장소(Persistent Store) 로드 및 초기화 실패 + case initializeFailed + + public var errorDescription: String? { + switch self { + case .resourceNotFound: + return "모델 파일(.momd)을 찾을 수 없습니다." + case .initializeFailed: + return "영구 저장소(Persistent Store) 로드 및 초기화에 실패했습니다." + } + } +} diff --git a/Data/Sources/Infrastructure/CoreData/FRCStreamDelegate.swift b/Data/Sources/Infrastructure/CoreData/FRCStreamDelegate.swift new file mode 100644 index 00000000..305f84fd --- /dev/null +++ b/Data/Sources/Infrastructure/CoreData/FRCStreamDelegate.swift @@ -0,0 +1,18 @@ +import CoreData + +/// NSFetchedResultsControllerDelegate를 클로저 기반으로 브릿지합니다. +/// Repository에서 NSFetchedResultsController를 AsyncStream으로 감쌀 때 사용합니다. +@MainActor +final class FRCStreamDelegate: NSObject, NSFetchedResultsControllerDelegate { + private let onChange: @MainActor () -> Void + + init(onChange: @escaping @MainActor () -> Void) { + self.onChange = onChange + } + + nonisolated func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + MainActor.assumeIsolated { + onChange() + } + } +} diff --git a/Data/Sources/Infrastructure/FileManager/FileManagerStorageService.swift b/Data/Sources/Infrastructure/FileManager/FileManagerStorageService.swift new file mode 100644 index 00000000..c0035ecd --- /dev/null +++ b/Data/Sources/Infrastructure/FileManager/FileManagerStorageService.swift @@ -0,0 +1,176 @@ +import Core +import Foundation + +/// 파일 시스템 기반의 스토리지 서비스 구현체 +public struct FileManagerStorageService: StorageService, @unchecked Sendable { + private let fileManager: FileManager + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + public func generateTemporaryURL(fileName: String) throws(StorageServiceError) -> URL { + AppLogger.debug("임시 URL 생성 시작: \(fileName)") + + if Task.isCancelled { + AppLogger.debug("작업 취소됨: generateTemporaryURL") + throw StorageServiceError.cancelled + } + + let tempDirectory = fileManager.temporaryDirectory + let directoryURL = tempDirectory.appendingPathComponent(UUID().uuidString) + let fileURL = directoryURL.appendingPathComponent(fileName) + + do { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + AppLogger.debug("임시 디렉토리 생성 완료: \(directoryURL.path)") + return fileURL + } catch { + AppLogger.error("임시 디렉토리 생성 실패: \(error)") + throw StorageServiceError.uncreatableTemporaryPath + } + } + + public func moveFile( + from sourceURL: URL, + toDirectory directory: String, + fileName: String + ) throws(StorageServiceError) -> String { + AppLogger.debug("파일 이동 시작: \(sourceURL.lastPathComponent) -> \(directory)/\(fileName)") + + if Task.isCancelled { + AppLogger.debug("작업 취소됨: moveFile") + throw StorageServiceError.cancelled + } + + guard fileManager.fileExists(atPath: sourceURL.path) else { + AppLogger.error("원본 파일을 찾을 수 없음: \(sourceURL.path)") + throw StorageServiceError.fileNotFound + } + + guard let documentURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + AppLogger.error("Document 디렉토리를 찾을 수 없음") + throw StorageServiceError.moveFailed + } + + let directoryURL = documentURL.appendingPathComponent(directory) + let destinationURL = directoryURL.appendingPathComponent(fileName) + + do { + if !fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + AppLogger.debug("디렉토리 생성됨: \(directoryURL.path)") + } + + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + AppLogger.debug("기존 파일 삭제됨 (덮어쓰기): \(destinationURL.path)") + } + + if Task.isCancelled { + AppLogger.debug("작업 취소됨: moveFile (이동 전)") + throw StorageServiceError.cancelled + } + + try fileManager.moveItem(at: sourceURL, to: destinationURL) + AppLogger.info("파일 이동 성공: \(destinationURL.path)") + return "\(directory)/\(fileName)" + } catch { + AppLogger.error("파일 이동 실패: \(error)") + throw StorageServiceError.moveFailed + } + } + + public func save( + data: Data, + toDirectory directory: String, + fileName: String + ) throws(StorageServiceError) -> String { + AppLogger.debug("파일 저장 시작: \(directory)/\(fileName) (size: \(data.count) bytes)") + + if Task.isCancelled { + AppLogger.debug("작업 취소됨: save") + throw StorageServiceError.cancelled + } + + guard let documentURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + AppLogger.error("Document 디렉토리를 찾을 수 없음") + throw StorageServiceError.writeFailed + } + + let directoryURL = documentURL.appendingPathComponent(directory) + let fileURL = directoryURL.appendingPathComponent(fileName) + + do { + if !fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + AppLogger.debug("디렉토리 생성됨: \(directoryURL.path)") + } + + if Task.isCancelled { + AppLogger.debug("작업 취소됨: save (쓰기 전)") + throw StorageServiceError.cancelled + } + + try data.write(to: fileURL, options: .atomic) + AppLogger.info("파일 저장 성공: \(fileURL.path)") + return "\(directory)/\(fileName)" + } catch { + AppLogger.error("파일 저장 실패: \(error)") + throw StorageServiceError.writeFailed + } + } + + public func load(relativePath: String) throws(StorageServiceError) -> Data { + let absoluteURL = absoluteURL(for: relativePath) + AppLogger.debug("파일 로드 시작: \(absoluteURL.path)") + + if Task.isCancelled { + AppLogger.debug("작업 취소됨: load") + throw StorageServiceError.cancelled + } + + guard fileManager.fileExists(atPath: absoluteURL.path) else { + AppLogger.error("파일을 찾을 수 없음: \(absoluteURL.path)") + throw StorageServiceError.fileNotFound + } + + do { + let data = try Data(contentsOf: absoluteURL) + AppLogger.debug("파일 로드 성공: \(absoluteURL.path) (\(data.count) bytes)") + return data + } catch { + AppLogger.error("파일 로드 실패: \(error)") + throw StorageServiceError.readFailed + } + } + + public func delete(fileURL: URL) throws(StorageServiceError) { + AppLogger.debug("임시 파일 삭제 시작: \(fileURL.path)") + + guard fileManager.fileExists(atPath: fileURL.path) else { + AppLogger.error("삭제할 파일을 찾을 수 없음: \(fileURL.path)") + throw StorageServiceError.fileNotFound + } + + do { + try fileManager.removeItem(at: fileURL) + AppLogger.info("임시 파일 삭제 성공: \(fileURL.path)") + } catch { + AppLogger.error("임시 파일 삭제 실패: \(error)") + throw StorageServiceError.deleteFailed + } + } + + public func exists(relativePath: String) -> Bool { + let absoluteURL = absoluteURL(for: relativePath) + let isExists = fileManager.fileExists(atPath: absoluteURL.path) + AppLogger.debug("파일 존재 확인 (\(isExists)): \(absoluteURL.path)") + return isExists + } + + public func absoluteURL(for relativePath: String) -> URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent(relativePath) + } +} diff --git a/Data/Sources/Infrastructure/KeyValueStore/UserDefaultsKeyValueStoreService.swift b/Data/Sources/Infrastructure/KeyValueStore/UserDefaultsKeyValueStoreService.swift new file mode 100644 index 00000000..b53f2b2e --- /dev/null +++ b/Data/Sources/Infrastructure/KeyValueStore/UserDefaultsKeyValueStoreService.swift @@ -0,0 +1,26 @@ +import Foundation + +/// UserDefaults 기반 KeyValueStoreService 구현체. +public struct UserDefaultsKeyValueStoreService: KeyValueStoreService, @unchecked Sendable { + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func bool(forKey key: String) -> Bool? { + defaults.object(forKey: key) as? Bool + } + + public func string(forKey key: String) -> String? { + defaults.string(forKey: key) + } + + public func set(_ value: Bool, forKey key: String) { + defaults.set(value, forKey: key) + } + + public func set(_ value: String, forKey key: String) { + defaults.set(value, forKey: key) + } +} diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXHubDownloader.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXHubDownloader.swift new file mode 100644 index 00000000..fbc147ea --- /dev/null +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXHubDownloader.swift @@ -0,0 +1,36 @@ +import Foundation +import HuggingFace +import MLXHuggingFace +import MLXLMCommon + +/// Hugging Face Hub에서 모델 파일을 다운로드하기 위한 Downloader 구현체. +/// 컴파일 타임 매크로(#hubDownloader) 대신 사용되는 수동 구현체입니다. +public struct MLXHubDownloader: MLXLMCommon.Downloader { + private let upstream: HuggingFace.HubClient + + public init(hubClient: HuggingFace.HubClient = HuggingFace.HubClient()) { + upstream = hubClient + } + + public func download( + id: String, + revision: String?, + matching patterns: [String], + useLatest: Bool, + progressHandler: @Sendable @escaping (Foundation.Progress) -> Void + ) async throws -> URL { + guard let repoID = HuggingFace.Repo.ID(rawValue: id) else { + throw HuggingFaceDownloaderError.invalidRepositoryID(id) + } + let revision = revision ?? "main" + + return try await upstream.downloadSnapshot( + of: repoID, + revision: revision, + matching: patterns, + progressHandler: { @MainActor progress in + progressHandler(progress) + } + ) + } +} diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift new file mode 100644 index 00000000..17f21e77 --- /dev/null +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift @@ -0,0 +1,212 @@ +import Core +import Domain +import Foundation +import HuggingFace +import MLX +import MLXHuggingFace +import MLXLLM +import MLXLMCommon +import Tokenizers + +/// 데이터 레이어 내부에서 MLX 모델 컨테이너를 공유하고 생명주기를 관리하는 프로바이더. +public actor MLXModelProvider: MLXModelDataSource { + private let storageService: any StorageService + + public init(storageService: any StorageService) { + self.storageService = storageService + } + + /// 메모리에 로드된 모델 컨테이너. 로드되지 않았을 경우 nil입니다. + private var container: ResolvedModelConfiguration? + + public func download( + progressHandler: @Sendable @escaping (Progress) -> Void + ) async throws(MLXModelDataSourceError) { +// #if DEBUG +// // ⚠️ 디버깅용 시뮬레이션: 에러 상황별 핸들링을 안전하게 테스트하기 위한 디버그 스위치입니다. +// try? await Task.sleep(nanoseconds: 2 * 1_000_000_000) +// +// // [옵션 1] 네트워크 오류 시뮬레이션 (networkFailed) +// // -> 활성화 시 "네트워크 연결이 유실되었습니다" 문구가 노출됩니다. +// // throw URLError(.notConnectedToInternet) +// +// // [옵션 2] 알 수 없는 시스템 오류 시뮬레이션 (unknown) +// // -> 활성화 시 "다운로드에 실패했습니다" 문구와 상세 에러 문구가 노출됩니다. +// throw .unknown(NSError( +// domain: "SimulatedErrorDomain", +// code: 999, +// userInfo: [NSLocalizedDescriptionKey: "알 수 없는 기기 내부 디스크 쓰기 오류가 발생했습니다. (Simulated)"] +// )) +// #endif + do { + let model: ChaGokModel = ChaGokModelSupport.current.model + let configuration = try matchModelConfiguration(model: model) + let path = try await resolve( + configuration: configuration, + from: MLXHubDownloader(), + useLatest: false, + progressHandler: progressHandler + ) + container = path + } catch { + if error is CancellationError || + (error as? URLError)?.code == .cancelled || + (error as NSError).domain == NSURLErrorDomain && (error as NSError).code == NSURLErrorCancelled + { + throw .cancelled + } + AppLogger.error(error) + throw .downloadFailed + } + } + + /// 메모리에서 모델을 해제합니다. + public func clear() { + if container != nil { + MLX.Memory.cacheLimit = 0 + container = nil + } + } + + /// 모델이 설치된 경로를 전달 하기 위한 함수 + public func getDownloadPath() async throws(MLXModelDataSourceError) -> URL { + if let path: URL = container?.modelDirectory { + AppLogger.info("MLX 저장 위치 (캐시) : \(path)") + return path + } + + // 앱 재시작 시 메모리 초기화에 대응하기 위해 디스크의 물리적인 경로 체크 + let model = ChaGokModelSupport.current.model + do { + let configuration = try matchModelConfiguration(model: model) + + // 1. 디렉토리 모델 처리 + if case .directory(let url) = configuration.id { + let modelURL = url.scheme == nil ? storageService.absoluteURL(for: url.path) : url + if FileManager.default.fileExists(atPath: modelURL.path) { + return modelURL + } + } + + // 2. 허브 모델(.id) 처리 + if case .id(let name, _) = configuration.id { + let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let repoFolderName = "models--" + name.replacingOccurrences(of: "/", with: "--") + let snapshotsURL = cachesURL.appendingPathComponent("huggingface/hub/\(repoFolderName)/snapshots") + + if let firstSnapshot = try? FileManager.default.contentsOfDirectory( + at: snapshotsURL, + includingPropertiesForKeys: nil + ).first { + // 완결성 검사: 모델 폴더 내부에 핵심 설정인 config.json 및 tokenizer 파일들이 완벽하게 다운로드되어 존재하는지 검사하여 C++ 크래시를 예방합니다. + let configURL = firstSnapshot.appendingPathComponent("config.json") + let tokenizerURL = firstSnapshot.appendingPathComponent("tokenizer.json") + let tokenizerConfigURL = firstSnapshot.appendingPathComponent("tokenizer_config.json") + + if FileManager.default.fileExists(atPath: configURL.path), + FileManager.default.fileExists(atPath: tokenizerURL.path), + FileManager.default.fileExists(atPath: tokenizerConfigURL.path) + { + return firstSnapshot + } + } + } + + throw MLXModelDataSourceError.notFound + } catch { + throw .notFound + } + } + + public nonisolated func loadModel() async throws(MLXModelDataSourceError) -> ModelContext { + do { + let from: URL = try await getDownloadPath() + let context = try await LLMModelFactory.shared.load(from: from, using: MLXTokenizerLoader()) + AppLogger.info("MLX model loaded: \(context)") + return context + } catch is CancellationError { + throw .cancelled + } catch let error as MLXModelDataSourceError { + AppLogger.error(error) + throw error + } catch { + if error is CancellationError || + (error as? URLError)?.code == .cancelled || + (error as NSError).domain == NSURLErrorDomain && (error as NSError).code == NSURLErrorCancelled + { + throw .cancelled + } + AppLogger.error(error) + throw .unknown(error) + } + } + + public func delete() async throws(MLXModelDataSourceError) { + defer { + clear() + } + do { + let model = ChaGokModelSupport.current.model + let configuration = try matchModelConfiguration(model: model) + + // 1. container가 존재하는 경우 바로 지우기 + if let resolvedDirectory = container?.modelDirectory { + let deleteURL: URL = switch configuration.id { + case .directory: + resolvedDirectory + case .id: + resolvedDirectory.deletingLastPathComponent().deletingLastPathComponent() + } + + if FileManager.default.fileExists(atPath: deleteURL.path) { + do { + try storageService.delete(fileURL: deleteURL) + } catch { + AppLogger.error("MLX 모델 삭제 경로 오류 : \(deleteURL)") + } + } + AppLogger.info("MLX 모델 삭제 완료 (container 기반): \(deleteURL.path)") + return + } + + // 2. container가 없는 경우 디스크 물리 경로를 찾아서 지우기 + let deleteURL: URL + switch configuration.id { + case .directory(let url): + deleteURL = url.scheme == nil ? storageService.absoluteURL(for: url.path) : url + case .id(let name, _): + let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let repoFolderName = "models--" + name.replacingOccurrences(of: "/", with: "--") + deleteURL = cachesURL.appendingPathComponent("huggingface/hub/\(repoFolderName)") + } + + if FileManager.default.fileExists(atPath: deleteURL.path) { + do { + try storageService.delete(fileURL: deleteURL) + } catch { + AppLogger.info("MLX 모델 삭제 실패 (물리 경로 기반): \(deleteURL.path)") + } + } + AppLogger.info("MLX 모델 삭제 완료 (물리 경로 기반): \(deleteURL.path)") + } catch { + AppLogger.error(error) + throw MLXModelDataSourceError.deleteFailed + } + } +} + +// MARK: - Private + +extension MLXModelProvider { + /// Domain 객체를 통해 mlx-swift-lm의 LLMRegistry를 변환 합니다. + private func matchModelConfiguration(model: ChaGokModel) throws(AvailableModelSupportRepositoryError) + -> ModelConfiguration + { + switch model { + case .gemma4_e2b_4bit: + return LLMRegistry.gemma4_e2b_it_4bit + case .none, .whisper: + throw .notFoundModel + } + } +} diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXTokenizerLoader.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXTokenizerLoader.swift new file mode 100644 index 00000000..cb5282cf --- /dev/null +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXTokenizerLoader.swift @@ -0,0 +1,64 @@ +import Foundation +import MLXLMCommon +import Tokenizers + +/// Hugging Face 기반 토크나이저를 로드하기 위한 TokenizerLoader 구현체. +/// 컴파일 타임 매크로(#huggingFaceTokenizerLoader) 대신 사용되는 수동 구현체입니다. +public struct MLXTokenizerLoader: MLXLMCommon.TokenizerLoader { + public init() {} + + public func load(from directory: URL) async throws -> any MLXLMCommon.Tokenizer { + let upstream = try await Tokenizers.AutoTokenizer.from(modelFolder: directory) + return TokenizerBridge(upstream) + } +} + +private struct TokenizerBridge: MLXLMCommon.Tokenizer { + private let upstream: any Tokenizers.Tokenizer + + init(_ upstream: any Tokenizers.Tokenizer) { + self.upstream = upstream + } + + func encode(text: String, addSpecialTokens: Bool) -> [Int] { + upstream.encode(text: text, addSpecialTokens: addSpecialTokens) + } + + func decode(tokenIds: [Int], skipSpecialTokens: Bool) -> String { + upstream.decode(tokens: tokenIds, skipSpecialTokens: skipSpecialTokens) + } + + func convertTokenToId(_ token: String) -> Int? { + upstream.convertTokenToId(token) + } + + func convertIdToToken(_ id: Int) -> String? { + upstream.convertIdToToken(id) + } + + var bosToken: String? { + upstream.bosToken + } + + var eosToken: String? { + upstream.eosToken + } + + var unknownToken: String? { + upstream.unknownToken + } + + func applyChatTemplate( + messages: [[String: any Sendable]], + tools: [[String: any Sendable]]?, + additionalContext: [String: any Sendable]? + ) throws -> [Int] { + do { + return try upstream.applyChatTemplate( + messages: messages, tools: tools, additionalContext: additionalContext + ) + } catch Tokenizers.TokenizerError.missingChatTemplate { + throw MLXLMCommon.TokenizerError.missingChatTemplate + } + } +} diff --git a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift new file mode 100644 index 00000000..e764652b --- /dev/null +++ b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift @@ -0,0 +1,208 @@ +import Core +import Domain +import Foundation +import WhisperKit + +public actor WhisperKitProvider: WhisperDataSource { + private let storageService: any StorageService + private let languageRepository: any LanguageRepository + + // MARK: - Configuration + + private var recommendedModel: String? + private var cachedWhisper: WhisperKit? + private var modelDirectory: URL? + private var decodingOptions: DecodingOptions { + DecodingOptions( + language: whisperLanguageCode(for: languageRepository.fetchLanguage()), + skipSpecialTokens: true + ) + } + + public init( + storageService: any StorageService, + languageRepository: any LanguageRepository + ) { + self.storageService = storageService + self.languageRepository = languageRepository + } + + 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 + + let path = try await WhisperKit.download( + variant: recommendedModel, + useBackgroundSession: false, + progressCallback: progressHandler + ) + + modelDirectory = path + AppLogger.info("WhisperKit 모델 위치 : \(modelDirectory?.path() ?? "없음")") + } + + private func getWhisper() async throws(WhisperDataSourceError) -> WhisperKit { + if let cached = cachedWhisper { + return cached + } + + do { + let downloadBase = try await getDownloadPath() + AppLogger.info("WhisperKit 모델 로드 시작: \(downloadBase.path)") + let modelName = recommendedModel ?? WhisperKit.recommendedModels().default + + let config = WhisperKitConfig( + model: modelName, + downloadBase: downloadBase, + modelFolder: downloadBase.path, + tokenizerFolder: downloadBase, + download: false + ) + + let whisper = try await WhisperKit(config) + + cachedWhisper = whisper + AppLogger.info("WhisperKit 모델 로드 완료") + try await whisper.prewarmModels() // preload + return whisper + } catch is CancellationError { + throw .cancelled + } catch let error as WhisperDataSourceError { + AppLogger.error(error) + throw error + } catch { + AppLogger.error(error) + throw .unknown(error) + } + } + + public func preload() async { + do { + _ = try await getDownloadPath() + _ = try await getWhisper() + } catch { + AppLogger.error(error) + } + } + + public func loadModel() async throws(WhisperDataSourceError) { + do { + let whisper = try await getWhisper() + try await whisper.loadModels() + } catch is CancellationError { + throw .cancelled + } catch let error as WhisperDataSourceError { + AppLogger.error(error) + throw error + } catch { + AppLogger.error(error) + throw .loadFailed + } + } + + public func clearCache() async { + guard let cachedWhisper else { return } + await cachedWhisper.unloadModels() + } + + /// 모델이 설치된 경로를 전달 하기 위한 함수 + public func getDownloadPath() async throws(WhisperDataSourceError) -> URL { + if let path = modelDirectory { + AppLogger.info("whisper 저장 위치 (캐시) : \(path)") + return path + } + + // 앱 재시작 시 메모리 초기화에 대응하기 위해 디스크의 물리적인 경로 체크 + let recommendedModel = WhisperKit.recommendedModels().default + let relativePath = "huggingface/models/argmaxinc/whisperkit-coreml/\(recommendedModel)" + let defaultPath = storageService.absoluteURL(for: relativePath) + + if storageService.exists(relativePath: relativePath) { + modelDirectory = defaultPath + self.recommendedModel = recommendedModel + AppLogger.info("whisper 저장 위치 (디스크 감지) : \(defaultPath)") + return defaultPath + } + + throw .notFound + } + + public func getDocodingOptions() -> DecodingOptions { + return decodingOptions + } + + public func transcribe(audioPath: URL) async throws -> [TranscriptionResult] { + let whisper = try await getWhisper() + + AppLogger.info("오디오 전사 실행: \(audioPath)") + return try await whisper.transcribe( + audioPath: audioPath.path, + decodeOptions: decodingOptions + ) + } + + public func delete() async throws { + defer { + modelDirectory = nil + } + do { + // 캐시된 경로가 있거나 디스크 감지가 되는 경우 해당 경로를 사용 + let downloadURL: URL + if let path = try? await getDownloadPath() { + downloadURL = path + } else { + // 다운로드 중 취소된 경우 등의 대비를 위해 기본 임시/일부 다운로드 경로 계산 + let model = recommendedModel ?? WhisperKit.recommendedModels().default + let relativePath = "huggingface/models/argmaxinc/whisperkit-coreml/\(model)" + downloadURL = storageService.absoluteURL(for: relativePath) + } + + do { + try storageService.delete(fileURL: downloadURL) + } catch { + // error는 자동으로 StorageServiceError로 강하게 추론됩니다. + guard case .fileNotFound = error else { + throw error + } + } + AppLogger.info("WhisperKit 모델/임시 폴더 삭제 완료: \(downloadURL.path)") + + await clearCache() + } catch { + AppLogger.error(error) + throw error + } + } +} + +// MARK: - Helper ( Private ) + +private extension WhisperKitProvider { + func whisperLanguageCode(for language: Language) -> String { + switch language { + case .ko: + return "ko" + case .en: + return "en" + } + } +} diff --git a/Data/Sources/Interfaces/KeyValueStore/KeyValueStoreService.swift b/Data/Sources/Interfaces/KeyValueStore/KeyValueStoreService.swift new file mode 100644 index 00000000..6f345824 --- /dev/null +++ b/Data/Sources/Interfaces/KeyValueStore/KeyValueStoreService.swift @@ -0,0 +1,7 @@ +/// UserDefaults 등 키-값 저장소에 대한 추상화 프로토콜. +public protocol KeyValueStoreService: Sendable { + func bool(forKey key: String) -> Bool? + func string(forKey key: String) -> String? + func set(_ value: Bool, forKey key: String) + func set(_ value: String, forKey key: String) +} diff --git a/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift new file mode 100644 index 00000000..62fec1b2 --- /dev/null +++ b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSource.swift @@ -0,0 +1,26 @@ +import Foundation +import HuggingFace +import MLXHuggingFace +import MLXLLM +import MLXLMCommon +import Tokenizers + +/// MLX 모델 컨테이너를 공유하고 생명주기를 관리하는 데이터 소스 인터페이스. +public protocol MLXModelDataSource: Sendable { + /// 다운로드 + func download( + progressHandler: @Sendable @escaping (Progress) -> Void + ) async throws(MLXModelDataSourceError) + + /// 메모리에서 모델을 해제하여 리소스를 반환합니다. + func clear() async + + /// 다운로드 경로를 전달합니다 + func getDownloadPath() async throws(MLXModelDataSourceError) -> URL + + /// 다운로드된 모델을 메모리에 로드합니다. + func loadModel() async throws(MLXModelDataSourceError) -> ModelContext + + /// 모델을 제거합니다. + func delete() async throws(MLXModelDataSourceError) +} diff --git a/Data/Sources/Interfaces/MLXSupport/MLXModelDataSourceError.swift b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSourceError.swift new file mode 100644 index 00000000..430944b6 --- /dev/null +++ b/Data/Sources/Interfaces/MLXSupport/MLXModelDataSourceError.swift @@ -0,0 +1,28 @@ +import Foundation + +/// MLXModel DataSource의 커스텀 에러 타입 정의 +public enum MLXModelDataSourceError: LocalizedError, Sendable { + /// Task 취소 + case cancelled + /// 네트워크 연결 실패 + case networkFailed + /// 설치 경로를 찾지 못함 + case notFound + /// 다운로드 실패 + case downloadFailed + /// 삭제 실패 + case deleteFailed + /// unknown + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: return "작업이 취소되었습니다" + case .networkFailed: return "네트워크 연결이 유실되었습니다" + case .notFound: return "설치 경로를 찾지 못합니다" + case .downloadFailed: return "다운로드에 실패했습니다" + case .deleteFailed: return "모델 경로 삭제 실패" + case .unknown(let error): return error.localizedDescription + } + } +} diff --git a/Data/Sources/Interfaces/Storage/StorageService.swift b/Data/Sources/Interfaces/Storage/StorageService.swift new file mode 100644 index 00000000..2a930495 --- /dev/null +++ b/Data/Sources/Interfaces/Storage/StorageService.swift @@ -0,0 +1,56 @@ +import Foundation + +/// 스토리지 서비스 프로토콜 +/// +/// - 임시 파일 (녹음 중): `URL` 기반 — 절대 경로로 즉시 접근 필요 +/// - 영구 파일 (Documents 저장): `String` 경로 기반 — 앱 컨테이너 변경에 안전한 상대 경로 +public protocol StorageService: Sendable { + /// 녹음 작업을 위한 임시 파일 URL을 생성합니다. + /// - Parameter fileName: 생성할 임시 파일의 이름 + /// - Returns: 생성된 임시 파일의 절대 URL + /// - Throws: `StorageServiceError.uncreatableTemporaryPath` + func generateTemporaryURL(fileName: String) throws(StorageServiceError) -> URL + + /// 임시 파일을 Documents 하위 디렉토리로 이동합니다. + /// - Parameters: + /// - sourceURL: 이동할 임시 파일의 절대 URL + /// - directory: 저장할 디렉토리 이름 + /// - fileName: 저장할 파일 이름 + /// - Returns: Documents 기준 상대 경로 (예: `"VoiceRecords/file.m4a"`) + /// - Throws: `StorageServiceError.moveFailed` + func moveFile( + from sourceURL: URL, + toDirectory directory: String, + fileName: String + ) throws(StorageServiceError) -> String + + /// 데이터를 Documents 하위 디렉토리에 파일로 저장합니다. + /// - Parameters: + /// - data: 저장할 데이터 + /// - directory: 저장할 디렉토리 이름 + /// - fileName: 저장할 파일 이름 + /// - Returns: Documents 기준 상대 경로 (예: `"Images/file.png"`) + /// - Throws: `StorageServiceError.writeFailed` + func save(data: Data, toDirectory directory: String, fileName: String) throws(StorageServiceError) -> String + + /// 영구 저장 파일을 읽어 Data로 반환합니다. + /// - Parameter relativePath: Documents 기준 상대 경로 (예: `"VoiceRecords/file.m4a"`) + /// - Returns: 파일의 Data + /// - Throws: `StorageServiceError.readFailed` + func load(relativePath: String) throws(StorageServiceError) -> Data + + /// 임시 파일을 삭제합니다. + /// - Parameter fileURL: 삭제할 임시 파일의 절대 URL + /// - Throws: `StorageServiceError.deleteFailed` + func delete(fileURL: URL) throws(StorageServiceError) + + /// 영구 저장 파일의 존재 여부를 확인합니다. + /// - Parameter relativePath: Documents 기준 상대 경로 (예: `"VoiceRecords/file.m4a"`) + /// - Returns: 파일 존재 여부 + func exists(relativePath: String) -> Bool + + /// 상대 경로를 현재 Documents 디렉토리 기준 절대 URL로 변환합니다. + /// - Parameter relativePath: Documents 기준 상대 경로 (예: `"VoiceRecords/file.m4a"`) + /// - Returns: 파일 시스템에서 실제로 접근 가능한 절대 URL + func absoluteURL(for relativePath: String) -> URL +} diff --git a/Data/Sources/Interfaces/Storage/StorageServiceError.swift b/Data/Sources/Interfaces/Storage/StorageServiceError.swift new file mode 100644 index 00000000..88ab5a36 --- /dev/null +++ b/Data/Sources/Interfaces/Storage/StorageServiceError.swift @@ -0,0 +1,33 @@ +import Foundation + +public enum StorageServiceError: LocalizedError, Sendable { + case fileNotFound + case uncreatableTemporaryPath + case moveFailed + case readFailed + case writeFailed + case deleteFailed + case cancelled + case unknown(any Error) + + public var errorDescription: String? { + switch self { + case .fileNotFound: + return "파일을 찾을 수 없습니다." + case .uncreatableTemporaryPath: + return "임시 파일 경로를 생성할 수 없습니다." + case .moveFailed: + return "파일 이동 실패" + case .readFailed: + return "파일 읽기 실패" + case .writeFailed: + return "파일 저장 실패" + case .deleteFailed: + return "파일 삭제 실패" + case .cancelled: + return "작업이 취소되었습니다." + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다: \(error.localizedDescription)" + } + } +} diff --git a/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift b/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift new file mode 100644 index 00000000..ece8b0eb --- /dev/null +++ b/Data/Sources/Interfaces/Whisper/WhisperDataSource.swift @@ -0,0 +1,29 @@ +import Foundation +import WhisperKit + +/// Whisper STT 모델 엔진을 제어하고 음성 전사 데이터를 제공하는 데이터 소스 인터페이스. +public protocol WhisperDataSource: Sendable { + /// 모델의 다운로드 경로를 전달합니다. + func getDownloadPath() async throws(WhisperDataSourceError) -> URL + + /// 다운로드 + func download(progressHandler: @Sendable @escaping (Progress) -> Void) async throws + + /// 캐싱된 Whisper 모델 인스턴스를 메모리에서 해제하여 자원을 반환합니다. + func clearCache() async + + /// 백그라운드에서 모델을 미리 로드하여 최초 음성 전사 속도를 향상시킵니다. + func preload() async + + /// 다운로드된 모델을 메모리에 로드합니다. + func loadModel() async throws(WhisperDataSourceError) + + /// STT 전사 transcribe + func transcribe(audioPath: URL) async throws -> [TranscriptionResult] + + /// DecodingOptions를 전달합니다 (`Getter`) + func getDocodingOptions() async -> DecodingOptions + + /// 모델을 제거합니다. + func delete() async throws +} diff --git a/Data/Sources/Interfaces/Whisper/WhisperDataSourceError.swift b/Data/Sources/Interfaces/Whisper/WhisperDataSourceError.swift new file mode 100644 index 00000000..9a541076 --- /dev/null +++ b/Data/Sources/Interfaces/Whisper/WhisperDataSourceError.swift @@ -0,0 +1,28 @@ +import Foundation + +/// WhisperKitl DataSource의 커스텀 에러 타입 정의 +public enum WhisperDataSourceError: LocalizedError, Sendable { + /// Task 취소 + case cancelled + /// 네트워크 연결 실패 + case networkFailed + /// 설치 경로를 찾지 못함 + case notFound + /// load 실패 시 + case loadFailed + /// 추천 모델이 없는 경우 + case notRecommendedModel + /// unknown + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: return "작업이 취소되었습니다" + case .networkFailed: return "네트워크 연결이 유실되었습니다" + case .notFound: return "설치 경로를 찾지 못합니다" + case .loadFailed: return "Whisper로드 실패" + case .notRecommendedModel: return "추천 모델이 없습니다" + case .unknown(let error): return error.localizedDescription + } + } +} diff --git a/Data/Sources/Repositories/Authority/DefaultCheckFirstLaunchRepository.swift b/Data/Sources/Repositories/Authority/DefaultCheckFirstLaunchRepository.swift new file mode 100644 index 00000000..87faa5d1 --- /dev/null +++ b/Data/Sources/Repositories/Authority/DefaultCheckFirstLaunchRepository.swift @@ -0,0 +1,22 @@ +import Domain +import Foundation + +public struct DefaultCheckFirstLaunchRepository: CheckFirstLaunchRepository { + private let store: any KeyValueStoreService + + public init(store: any KeyValueStoreService) { + self.store = store + } + + public func checkIsFirstLaunch() -> Bool { + return store.bool(forKey: Policy.isExistingUserKey) != true + } + + public func checkAndMarkFirstLaunch() -> Bool { + let isFirstLaunch = checkIsFirstLaunch() + if isFirstLaunch { + store.set(true, forKey: Policy.isExistingUserKey) + } + return isFirstLaunch + } +} diff --git a/Data/Sources/Repositories/Folders/DefaultFolderRepository.swift b/Data/Sources/Repositories/Folders/DefaultFolderRepository.swift new file mode 100644 index 00000000..31f91cfd --- /dev/null +++ b/Data/Sources/Repositories/Folders/DefaultFolderRepository.swift @@ -0,0 +1,164 @@ +import Core +import CoreData +import Domain +import Foundation + +/// Folders 도메인을 위한 리포지토리 실구현체입니다. +@MainActor +public struct DefaultFolderRepository: FolderRepository { + private let context: NSManagedObjectContext + + public init(context: NSManagedObjectContext) { + self.context = context + } + + public func create(_ folder: Folder) throws(FolderRepositoryError) -> Folder { + do { + let entity = FolderEntity(model: folder, context: context) + try context.save() + return entity.toModel() + } catch { + AppLogger.error(error) + throw .createFailed + } + } + + public func fetchAll() throws(FolderRepositoryError) -> [Folder] { + do { + let request = FolderEntity.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \FolderEntity.createdAt, ascending: false) + ] + return try context.fetch(request).map { $0.toModel() } + } catch { + AppLogger.error(error) + throw .fetchFailed + } + } + + public func fetch(by id: UUID) throws(FolderRepositoryError) -> Folder { + let request = FolderEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + + let result: [FolderEntity] + do { + result = try context.fetch(request) + } catch { + AppLogger.error(error) + throw .fetchFailed + } + + guard let entity = result.first else { + throw .notFound + } + return entity.toModel() + } + + public func fetch(by kind: FolderKind) throws(FolderRepositoryError) -> [Folder] { + do { + let request = FolderEntity.fetchRequest() + request.predicate = NSPredicate(format: "kindRaw == %@", kind.rawValue) + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \FolderEntity.createdAt, ascending: false) + ] + return try context.fetch(request).map { $0.toModel() } + } catch { + AppLogger.error(error) + throw .fetchFailed + } + } + + public func update(_ folder: Folder) throws(FolderRepositoryError) -> Folder { + do { + let request = FolderEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", folder.id as CVarArg) + request.fetchLimit = 1 + guard let entity = try context.fetch(request).first else { + throw FolderRepositoryError.notFound + } + entity.update(from: folder) + try context.save() + return entity.toModel() + } catch { + AppLogger.error(error) + throw .updateFailed + } + } + + public func observe(by kind: FolderKind) throws(FolderRepositoryError) -> AsyncStream<[Folder]> { + let request = FolderEntity.fetchRequest() + request.predicate = NSPredicate( + format: "kindRaw == %@ AND parentID == nil", + kind.rawValue + ) + request.sortDescriptors = [NSSortDescriptor(keyPath: \FolderEntity.createdAt, ascending: false)] + return try makeListStream(request: request) + } + + public func observeTrashed() throws(FolderRepositoryError) -> AsyncStream<[Folder]> { + let request = FolderEntity.fetchRequest() + request.predicate = NSPredicate( + format: "parentID != nil AND kindRaw == %@", + FolderKind.custom.rawValue + ) + request.sortDescriptors = [NSSortDescriptor(keyPath: \FolderEntity.deletedAt, ascending: false)] + return try makeListStream(request: request) + } + + public func delete(id: UUID) throws(FolderRepositoryError) { + do { + guard let entity = try fetchEntity(id: id) else { + throw FolderRepositoryError.notFound + } + context.delete(entity) // xcdatamodel cascade rule이 안의 노트도 삭제 + try context.save() + } catch { + AppLogger.error(error) + throw .updateFailed + } + } + + private func fetchEntity(id: UUID) throws -> FolderEntity? { + let request = FolderEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + return try context.fetch(request).first + } + + private func makeListStream( + request: NSFetchRequest + ) throws(FolderRepositoryError) -> AsyncStream<[Folder]> { + let frc = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + + do { + try frc.performFetch() + } catch { + AppLogger.error(error) + throw .fetchFailed + } + + nonisolated(unsafe) let sendableFRC = frc + + return AsyncStream { continuation in + let initial = (sendableFRC.fetchedObjects ?? []).map { $0.toModel() } + continuation.yield(initial) + + let delegate = FRCStreamDelegate { + let models = (sendableFRC.fetchedObjects ?? []).map { $0.toModel() } + continuation.yield(models) + } + sendableFRC.delegate = delegate + + continuation.onTermination = { _ in + sendableFRC.delegate = nil + _ = delegate + } + } + } +} diff --git a/Data/Sources/Repositories/Languages/DefaultLanguageRepository.swift b/Data/Sources/Repositories/Languages/DefaultLanguageRepository.swift new file mode 100644 index 00000000..32a31a98 --- /dev/null +++ b/Data/Sources/Repositories/Languages/DefaultLanguageRepository.swift @@ -0,0 +1,19 @@ +import Domain +import Foundation + +public struct DefaultLanguageRepository: LanguageRepository { + private let store: any KeyValueStoreService + + public init(store: any KeyValueStoreService) { + self.store = store + } + + public func fetchLanguage() -> Language { + guard let raw = store.string(forKey: Policy.appSelectedLanguageKey) else { return .ko } + return Language(rawValue: raw) ?? .ko + } + + public func saveLanguage(_ language: Language) { + store.set(language.rawValue, forKey: Policy.appSelectedLanguageKey) + } +} diff --git a/Data/Sources/Repositories/MLXSupport/DefaultAvailableModelSupportRepository.swift b/Data/Sources/Repositories/MLXSupport/DefaultAvailableModelSupportRepository.swift new file mode 100644 index 00000000..d5345889 --- /dev/null +++ b/Data/Sources/Repositories/MLXSupport/DefaultAvailableModelSupportRepository.swift @@ -0,0 +1,73 @@ +import Core +import Domain +import Foundation +import HuggingFace +import MLXHuggingFace +import MLXLLM +import MLXLMCommon + +/// 온디바이스 AI 모델의 지원 여부 확인 및 다운로드를 담당하는 리포지토리 구현체. +public final class DefaultAvailableModelSupportRepository: AvailableModelSupportRepository { + private let mlxProvider: any MLXModelDataSource + private let whisperProvider: any WhisperDataSource + + public init( + mlxProvider: any MLXModelDataSource, + whisperProvider: any WhisperDataSource + ) { + self.mlxProvider = mlxProvider + self.whisperProvider = whisperProvider + } + + /// 현재 기기의 사양을 확인하여 지원 가능한 모델 정보를 반환합니다. + public func checkMLXSupportModel() async -> ChaGokModelSupport { + return ChaGokModelSupport.current + } + + /// 현재 사용자의 On-Device LLM 모두 fetch 합니다. + public func fetchSupportModels() async -> [ChaGokModelState] { + let models: [ChaGokModel] = ChaGokModel.models + var whisperStatus = OnDeviceStatus(storage: .notDownloaded) + var mlxStatus = OnDeviceStatus(storage: .notDownloaded) + + do { + _ = try await whisperProvider.getDownloadPath() + whisperStatus = OnDeviceStatus(storage: .downloaded) + } catch { + AppLogger.info("Whisper 모델 다운로드 경로 없음: \(error.localizedDescription)") + } + + do { + _ = try await mlxProvider.getDownloadPath() + mlxStatus = OnDeviceStatus(storage: .downloaded) + } catch { + AppLogger.info("MLX 모델 다운로드 경로 없음: \(error.localizedDescription)") + } + + // gemma를 설치 할 수 있는지 여부 + let available: Bool = await checkMLXSupportModel().model == .gemma4_e2b_4bit + return models.compactMap { model in + switch model { + case .whisper: + return ChaGokModelState( + title: "Whisper", + subTitle: "기기에서 음성을 텍스트로 변환하기 위한\n필수 모델입니다.", + model: .whisper, + status: whisperStatus + ) + case .gemma4_e2b_4bit: + if available { + return ChaGokModelState( + title: "Gemma-4", + subTitle: "Ai 요약, 문법 교정을 통해 정확한 문장을 생성합니다.", + model: .gemma4_e2b_4bit, + status: mlxStatus + ) + } + return nil + default: + return nil + } + } + } +} diff --git a/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift b/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift new file mode 100644 index 00000000..07401b32 --- /dev/null +++ b/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift @@ -0,0 +1,75 @@ +import Core +import Domain +import Foundation + +public final class DefaultMlxOnDeviceRepository: OnDeviceRepository { + private let provider: any MLXModelDataSource + + public init( + provider: any MLXModelDataSource + ) { + self.provider = provider + } + + public func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { + do { + try await provider.download { progress in + progressHandler(progress.fractionCompleted) + } + } catch { + AppLogger.error(error.localizedDescription) + _ = try? await provider.delete() + throw mapError(error) + } + } + + private func mapError(_ error: Error) -> OnDeviceRepositoryError { + if let mlxError = error as? MLXModelDataSourceError { + switch mlxError { + case .cancelled: + return .cancelled + case .networkFailed: + return .networkFailed + default: + return .unknown(mlxError) + } + } + return OnDeviceRepositoryError.mapDownloadError(error) + } + + /// 다운로드 경로에 존재하는 모델 경로를 삭제합니다. + public func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus { + do { + try await provider.delete() + return OnDeviceStatus(storage: .notDownloaded) + } catch { + AppLogger.error(error) + switch error { + case .cancelled: + throw .cancelled + case .notFound, .downloadFailed, .deleteFailed: + return OnDeviceStatus(storage: .notDownloaded) + case .networkFailed: + throw .deleteMLXFailed + case .unknown(let underlying): + if underlying is CancellationError || + (underlying as? URLError)?.code == .cancelled || + (underlying as NSError).domain == NSURLErrorDomain && (underlying as NSError) + .code == NSURLErrorCancelled + { + throw .cancelled + } + throw .unknown(underlying) + } + } + } + + public func checkStatus() async -> OnDeviceStatus { + do { + _ = try await provider.getDownloadPath() + return OnDeviceStatus(storage: .downloaded) + } catch { + return OnDeviceStatus(storage: .notDownloaded) + } + } +} diff --git a/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift new file mode 100644 index 00000000..7c2ed777 --- /dev/null +++ b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift @@ -0,0 +1,59 @@ +import Core +import Domain +import Foundation + +/// Whisper 객체에 대한 기능 구현체를 담은 `Repository` +public struct DefaultWhisperOnDeviceRepository: OnDeviceRepository { + let provider: any WhisperDataSource + + public init( + provider: any WhisperDataSource + ) { + self.provider = provider + } + + public func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { + do { + try await provider.download { progress in + progressHandler(progress.fractionCompleted) + } + } catch { + AppLogger.error(error.localizedDescription) + _ = try? await provider.delete() + throw mapError(error) + } + } + + private func mapError(_ error: Error) -> OnDeviceRepositoryError { + if let whisperError = error as? WhisperDataSourceError { + switch whisperError { + case .cancelled: + return .cancelled + case .networkFailed, .notRecommendedModel: + return .networkFailed + default: + return .unknown(whisperError) + } + } + return OnDeviceRepositoryError.mapDownloadError(error) + } + + public func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus { + do { + try await provider.delete() + return OnDeviceStatus(storage: .notDownloaded) + } catch { + AppLogger.error(error) + throw .deleteWhisperFailed + } + } + + public func checkStatus() async -> OnDeviceStatus { + do { + _ = try await provider.getDownloadPath() + return OnDeviceStatus(storage: .downloaded) + } catch { + return OnDeviceStatus(storage: .notDownloaded) + } + } +} diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift new file mode 100644 index 00000000..915adcdd --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/DefaultMLXSummaryRepository.swift @@ -0,0 +1,88 @@ +import Core +import Domain +import Foundation +import HuggingFace +import MLXHuggingFace +import MLXLLM +import MLXLMCommon +import Tokenizers + +public struct DefaultMLXSummaryRepository: SummaryRepository { + private let provider: any MLXModelDataSource + + public init(provider: any MLXModelDataSource) { + self.provider = provider + } + + public func summarize( + transcript: Transcript, + language: Language + ) async throws(SummaryRepositoryError) -> (keywords: [Keyword], summary: Summary) { + if Task.isCancelled { throw .cancelled } + do { + // model load + let context: ModelContext = try await provider.loadModel() + let container: ModelContainer = ModelContainer(context: context) + // JSON 응답을 위한 스키마 강제 프롬프트 추가 + let jsonInstruction = """ + \(Policy.summaryPrompt(lang: language.rawValue)) + + IMPORTANT: You must output ONLY valid JSON matching this exact schema: + { + "keywords": ["keyword1", "keyword2", "keyword3"], + "keyPoints": ["point1", "point2"] + } + Do not include any other text or markdown tags. + """ + + let session = ChatSession( + container, + instructions: jsonInstruction + ) + + var summaryResponse = try await session.respond( + to: Policy.keywordPrompt( + transcript: transcript.sections.map(\.text).joined(separator: "\n") + ) + ) + + if let firstOpen = summaryResponse.firstIndex(of: "{"), + let lastClose = summaryResponse.lastIndex(of: "}") + { + summaryResponse = String(summaryResponse[firstOpen ... lastClose]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + guard let data = summaryResponse.data(using: .utf8) else { + AppLogger.error("summaryResponse Decoding 문제: \(summaryResponse)") + throw SummaryRepositoryError.summarizeFailed + } + + let result = try JSONDecoder().decode(MLXSummaryResult.self, from: data) + + let keywords = result.keywords + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { Keyword(noteID: transcript.id, word: $0) } + + let keyPoints = result.keyPoints + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !keyPoints.isEmpty else { + throw SummaryRepositoryError.summarizeFailed + } + + let summaryText = keyPoints.joined(separator: "\n") + await provider.clear() + return (keywords, Summary(text: summaryText)) + } catch { + await provider.clear() + AppLogger.error(error) + if let repoError = error as? SummaryRepositoryError { + throw repoError + } + throw .summarizeFailed + } + } +} diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultSTTRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultSTTRepository.swift new file mode 100644 index 00000000..9cd4da66 --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/DefaultSTTRepository.swift @@ -0,0 +1,260 @@ +import Core +import Domain +import Foundation +import Speech + +/// 음성 인식(STT) 리포지토리 기본 구현체. +public actor DefaultSTTRepository: STTRepository { + private let storageService: any StorageService + private let languageRepository: any LanguageRepository + private var currentTask: SFSpeechRecognitionTask? + private var currentContinuation: CheckedContinuation? + + /// Speech Framework에 동시 요청이 들어가지 않도록 `transcribe`를 FIFO로 순차화한다. + private var isBusy = false + private var waiters: [Waiter] = [] + + public init( + storageService: any StorageService, + languageRepository: any LanguageRepository + ) { + self.storageService = storageService + self.languageRepository = languageRepository + } + + public func transcribe(audioFilePath: String) async throws(STTRepositoryError) -> Transcript { + guard !Task.isCancelled else { throw .cancelled } + + try await acquireSlot() + defer { releaseSlot() } + + let absoluteURL = storageService.absoluteURL(for: audioFilePath) + AppLogger.info("음성 전사를 시작합니다: \(absoluteURL.lastPathComponent)") + + do { + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + do { + try self.startRecognitionTask( + audioFileURL: absoluteURL, + continuation: continuation + ) + } catch { + continuation.resume(throwing: error) + } + } + } onCancel: { + Task { await self.cancelCurrentTask() } + } + } catch let error as STTRepositoryError { + throw error + } catch { + throw mapToRepositoryError(from: error) + } + } + + // MARK: - Slot Queue + + /// 슬롯을 확보한다. 이미 진행 중인 전사가 있으면 FIFO로 대기한다. + /// 대기 중 호출자 Task가 취소되면 `.cancelled`를 던진다. + private func acquireSlot() async throws(STTRepositoryError) { + if !isBusy { + isBusy = true + return + } + let grantedSlot = await waitInQueue() + if !grantedSlot { throw .cancelled } + } + + /// FIFO 큐에 대기자를 추가하고 슬롯이 인계될 때까지 대기한다. + /// 반환값이 `true`면 슬롯을 획득했다는 뜻이며, `false`면 대기 중 취소된 것이다. + private func waitInQueue() async -> Bool { + let id = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + waiters.append(Waiter(id: id, continuation: continuation)) + } + } onCancel: { + Task { await self.cancelWaiter(id: id) } + } + } + + /// 슬롯을 반환한다. 대기자가 있으면 `isBusy`를 유지한 채 다음 호출자에게 슬롯을 인계한다. + private func releaseSlot() { + guard !waiters.isEmpty else { + isBusy = false + return + } + waiters.removeFirst().continuation.resume(returning: true) + } + + /// 대기 중 취소된 호출자를 큐에서 제거하고 `false`를 반환해 `.cancelled`로 빠지게 한다. + private func cancelWaiter(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + waiters.remove(at: index).continuation.resume(returning: false) + } + + public nonisolated func checkSTTPermission() -> PermissionStatus { + switch SFSpeechRecognizer.authorizationStatus() { + case .authorized: + return .authorized + case .denied, .restricted: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .denied + } + } + + public func requestSTTPermission() async throws(STTPermissionRepositoryError) -> PermissionStatus { + if Task.isCancelled { throw .cancelled } + + let status = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + + switch status { + case .authorized: + return .authorized + case .denied, .restricted: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .denied + } + } + + // MARK: - Private + + private func startRecognitionTask( + audioFileURL: URL, + continuation: CheckedContinuation + ) throws(STTRepositoryError) { + guard !Task.isCancelled else { throw .cancelled } + + let localeIdentifier = languageRepository.fetchLanguage().localeIdentifier + guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)) else { + AppLogger.error("SFSpeechRecognizer 초기화 실패 (\(localeIdentifier))") + throw .transcribeFailed + } + + if !recognizer.isAvailable { + AppLogger.warning("SFSpeechRecognizer를 현재 사용할 수 없는 상태입니다. (isAvailable = false)") + } + + let request = SFSpeechURLRecognitionRequest(url: audioFileURL) + // 현재 로캘/디바이스가 on-device 인식을 지원하면 오프라인 모드로, 아니면 서버 인식으로 폴백. + request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition + currentContinuation = continuation + + currentTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + + if let error { + Task { await self.failTask(error) } + return + } + + guard let result, result.isFinal else { return } + + let transcription = result.bestTranscription + let sections = Self.groupIntoSections(transcription.segments) + let transcript = Transcript(sections: sections) + AppLogger.info("음성 전사가 완료되었습니다. 섹션 수: \(sections.count)") + Task { await self.finishTask(transcript) } + } + } + + private func finishTask(_ result: Transcript) { + let continuation = currentContinuation + currentContinuation = nil + currentTask = nil + continuation?.resume(returning: result) + } + + private func failTask(_ error: Error) { + let continuation = currentContinuation + currentContinuation = nil + currentTask = nil + continuation?.resume(throwing: mapToRepositoryError(from: error)) + } + + private func cancelCurrentTask() { + let continuation = currentContinuation + currentContinuation = nil + currentTask?.cancel() + currentTask = nil + AppLogger.info("음성 전사가 취소되었습니다.") + continuation?.resume(throwing: STTRepositoryError.cancelled) + } + + private static func groupIntoSections(_ segments: [SFTranscriptionSegment]) -> [TranscriptSection] { + guard let first = segments.first else { return [] } + + var sections: [TranscriptSection] = [] + var currentTimestamp = first.timestamp + var currentWords: [String] = [first.substring] + + for i in 1 ..< segments.count { + let prev = segments[i - 1] + let curr = segments[i] + let gap = curr.timestamp - (prev.timestamp + prev.duration) + + if gap > Policy.scriptGroupingPauseThreshold { + let text = currentWords.joined(separator: " ") + sections.append(TranscriptSection(timestamp: currentTimestamp, text: text)) + currentTimestamp = curr.timestamp + currentWords = [curr.substring] + } else { + currentWords.append(curr.substring) + } + } + + if !currentWords.isEmpty { + let text = currentWords.joined(separator: " ") + sections.append(TranscriptSection(timestamp: currentTimestamp, text: text)) + } + + return sections + } + + private func mapToRepositoryError(from error: Error) -> STTRepositoryError { + if let repoError = error as? STTRepositoryError { return repoError } + + let nsError = error as NSError + + // Speech Framework의 사용자 취소 코드 (301) + if nsError.domain == "com.apple.speech.speechrecognitionframework", nsError.code == 301 { + return .cancelled + } + + // 인식 결과가 없는 경우 (203) + if nsError.domain == "kAFAssistantErrorDomain", nsError.code == 203 { + return .transcribeFailed + } + + // 추가 전사 오류 처리 (kLSRErrorDomain 300, kAFAssistantErrorDomain 1101) + if nsError.domain == "kLSRErrorDomain", nsError.code == 300 { + AppLogger.error("전사 실패: kLSRErrorDomain (300)") + return .transcribeFailed + } + + if nsError.domain == "kAFAssistantErrorDomain", nsError.code == 1101 { + AppLogger.error("전사 실패: kAFAssistantErrorDomain (1101)") + return .transcribeFailed + } + + AppLogger + .error("알 수 없는 전사 오류: \(error.localizedDescription) (Domain: \(nsError.domain), Code: \(nsError.code))") + return .unknown(error) + } +} + +private struct Waiter { + let id: UUID + let continuation: CheckedContinuation +} diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultSummaryRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultSummaryRepository.swift new file mode 100644 index 00000000..47181c8f --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/DefaultSummaryRepository.swift @@ -0,0 +1,65 @@ +import Core +import Domain +import Foundation +#if canImport(FoundationModels) + import FoundationModels +#endif + +/// 요약(Summary) 리포지토리 기본 구현체. +public struct DefaultSummaryRepository: SummaryRepository { + public init() {} + + public func summarize(transcript: Domain.Transcript, language: Domain.Language) async throws(SummaryRepositoryError) + -> (keywords: [Domain.Keyword], summary: Domain.Summary) + { + #if canImport(FoundationModels) + if Task.isCancelled { throw .cancelled } + + let model = SystemLanguageModel.default + guard model.isAvailable else { throw .summarizeFailed } + + let session = LanguageModelSession( + model: model, + instructions: Policy.summaryPrompt(lang: language.rawValue) + ) + + do { + let response = try await session.respond( + to: Policy.keywordPrompt( + transcript: transcript.sections.map(\.text).joined(separator: "\n") + ), + generating: SummaryGenerationResult.self + ) + + let keywords = response.content.keywords + .map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { Keyword(noteID: transcript.id, word: $0) } + + let keyPoints = response.content.keyPoints + .map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !keyPoints.isEmpty else { + throw SummaryRepositoryError.summarizeFailed + } + + let summaryText = keyPoints.joined(separator: "\n") + + return (keywords, Summary(text: summaryText)) + + } catch let error as LanguageModelSession.GenerationError { + AppLogger.error(error) + throw .summarizeFailed + } catch { + AppLogger.error(error) + if let repoError = error as? SummaryRepositoryError { + throw repoError + } + throw .unknown(error) + } + #else + throw .summarizeFailed + #endif + } +} diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultVoiceNoteRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultVoiceNoteRepository.swift new file mode 100644 index 00000000..ff9334dc --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/DefaultVoiceNoteRepository.swift @@ -0,0 +1,247 @@ +import Core +import CoreData +import Domain +import Foundation + +/// VoiceNote 통합 리포지토리 구현체. +@MainActor +public struct DefaultVoiceNoteRepository: VoiceNoteRepository { + private let context: NSManagedObjectContext + + public init(context: NSManagedObjectContext) { + self.context = context + } + + public func create(_ voiceNote: VoiceNote) throws(VoiceNoteRepositoryError) -> VoiceNote { + do { + let folderRequest = FolderEntity.fetchRequest() + folderRequest.predicate = NSPredicate(format: "id == %@", voiceNote.folderID as CVarArg) + folderRequest.fetchLimit = 1 + guard let folderEntity = try context.fetch(folderRequest).first else { + throw VoiceNoteRepositoryError.defaultFolderNotFound + } + + let voiceRecordEntity = VoiceRecordEntity(model: voiceNote.voiceRecord, context: context) + let voiceNoteEntity = VoiceNoteEntity(model: voiceNote, context: context) + voiceNoteEntity.folder = folderEntity + voiceNoteEntity.voiceRecord = voiceRecordEntity + voiceRecordEntity.voiceNote = voiceNoteEntity + + try context.save() + return voiceNote + } catch { + AppLogger.error(error) + throw .createFailed + } + } + + public func update(_ voiceNote: VoiceNote) throws(VoiceNoteRepositoryError) -> VoiceNote { + do { + // 1. 대상 entity 찾기 + let request = VoiceNoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", voiceNote.id as CVarArg) + request.fetchLimit = 1 + guard let voiceNoteEntity = try context.fetch(request).first else { + throw VoiceNoteRepositoryError.fetchFailed(id: voiceNote.id) + } + + // 2. scalar + voiceRecord 갱신 + voiceNoteEntity.update(from: voiceNote) + voiceNoteEntity.voiceRecord.update(from: voiceNote.voiceRecord) + + // 3. folder set (항상 갱신) + let folderRequest = FolderEntity.fetchRequest() + folderRequest.predicate = NSPredicate(format: "id == %@", voiceNote.folderID as CVarArg) + folderRequest.fetchLimit = 1 + guard let folderEntity = try context.fetch(folderRequest).first else { + throw VoiceNoteRepositoryError.defaultFolderNotFound + } + voiceNoteEntity.folder = folderEntity + + // 4. transcript 동기화 + switch (voiceNote.transcript, voiceNoteEntity.transcript) { + case (let model?, let entity?): + entity.update(from: model) + case (let model?, nil): + let new = TranscriptEntity(model: model, context: context) + new.voiceNote = voiceNoteEntity + voiceNoteEntity.transcript = new + case (nil, let old?): + context.delete(old) + voiceNoteEntity.transcript = nil + case (nil, nil): + break + } + + // 5. summary 동기화 + switch (voiceNote.summary, voiceNoteEntity.summary) { + case (let model?, let entity?): + entity.update(from: model) + case (let model?, nil): + let new = SummaryEntity(model: model, context: context) + new.voiceNote = voiceNoteEntity + voiceNoteEntity.summary = new + case (nil, let old?): + context.delete(old) + voiceNoteEntity.summary = nil + case (nil, nil): + break + } + + // 6. keywords 덮어쓰기 (기존 삭제 후 새로 생성) + for entity in (voiceNoteEntity.keywords as? Set) ?? [] { + context.delete(entity) + } + for keywordModel in voiceNote.keywords { + let new = KeywordEntity(model: keywordModel, context: context) + new.voiceNote = voiceNoteEntity + } + + try context.save() + return voiceNoteEntity.toModel() + } catch { + AppLogger.error(error) + throw .updateFailed + } + } + + public func fetch(byId id: UUID) throws(VoiceNoteRepositoryError) -> VoiceNote { + do { + let request = VoiceNoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + guard let entity = try context.fetch(request).first else { + throw VoiceNoteRepositoryError.fetchFailed(id: id) + } + return entity.toModel() + } catch { + AppLogger.error(error) + throw .fetchFailed(id: id) + } + } + + public func observe(id: UUID) throws(VoiceNoteRepositoryError) -> AsyncStream { + let request = VoiceNoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.sortDescriptors = [NSSortDescriptor(keyPath: \VoiceNoteEntity.createdAt, ascending: false)] + request.fetchLimit = 1 + + let frc = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + + do { + try frc.performFetch() + guard let initial = frc.fetchedObjects?.first else { + throw VoiceNoteRepositoryError.fetchFailed(id: id) + } + + nonisolated(unsafe) let sendableFRC = frc + + return AsyncStream { continuation in + continuation.yield(initial.toModel()) + + let delegate = FRCStreamDelegate { + if let entity = frc.fetchedObjects?.first { + continuation.yield(entity.toModel()) + } else { + continuation.finish() + } + } + frc.delegate = delegate + + continuation.onTermination = { _ in + sendableFRC.delegate = nil + _ = delegate + } + } + } catch { + AppLogger.error(error) + throw .fetchFailed(id: id) + } + } + + public func observe(folderID: UUID) throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> { + let request = VoiceNoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "folder.id == %@ AND deletedAt == nil", folderID as CVarArg) + request.sortDescriptors = [NSSortDescriptor(keyPath: \VoiceNoteEntity.createdAt, ascending: false)] + return try makeListStream(request: request) { .fetchAllFailed(folderID: folderID) } + } + + public func observeRecent(limit: Int) throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> { + let request = VoiceNoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "deletedAt == nil AND folder.parentID == nil") + request.sortDescriptors = [NSSortDescriptor(keyPath: \VoiceNoteEntity.createdAt, ascending: false)] + request.fetchLimit = limit + return try makeListStream(request: request) { .fetchRecentFailed } + } + + public func observeTrashed() throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> { + let request = VoiceNoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "deletedAt != nil") + request.sortDescriptors = [NSSortDescriptor(keyPath: \VoiceNoteEntity.deletedAt, ascending: false)] + return try makeListStream(request: request) { .fetchRecentFailed } + } + + public func delete(id: UUID) throws(VoiceNoteRepositoryError) { + do { + guard let entity = try fetchEntity(id: id) else { + throw VoiceNoteRepositoryError.fetchFailed(id: id) + } + context.delete(entity) + try context.save() + } catch { + AppLogger.error(error) + throw .updateFailed + } + } + + private func fetchEntity(id: UUID) throws -> VoiceNoteEntity? { + let request = VoiceNoteEntity.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + return try context.fetch(request).first + } + + /// VoiceNoteEntity NSFetchRequest를 NSFetchedResultsController로 감싸서 + /// AsyncStream<[VoiceNote]>로 변환합니다. + private func makeListStream( + request: NSFetchRequest, + errorOnFailure: () -> VoiceNoteRepositoryError + ) throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> { + let frc = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + + do { + try frc.performFetch() + } catch { + AppLogger.error(error) + throw errorOnFailure() + } + + nonisolated(unsafe) let sendableFRC = frc + + return AsyncStream { continuation in + let initial = (frc.fetchedObjects ?? []).map { $0.toModel() } + continuation.yield(initial) + + let delegate = FRCStreamDelegate { + let models = (frc.fetchedObjects ?? []).map { $0.toModel() } + continuation.yield(models) + } + frc.delegate = delegate + + continuation.onTermination = { _ in + sendableFRC.delegate = nil + _ = delegate + } + } + } +} diff --git a/Data/Sources/Repositories/VoiceNotes/DefaultWhisperSTTRepository.swift b/Data/Sources/Repositories/VoiceNotes/DefaultWhisperSTTRepository.swift new file mode 100644 index 00000000..ef5be3e1 --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/DefaultWhisperSTTRepository.swift @@ -0,0 +1,88 @@ +import Core +import Domain +import Foundation +import Speech +import WhisperKit + +public struct DefaultWhisperSTTRepository: STTRepository, @unchecked Sendable { + private let storageService: any StorageService + private let dataSource: any WhisperDataSource + + public init( + storageService: any StorageService, + dataSource: any WhisperDataSource + ) { + self.storageService = storageService + self.dataSource = dataSource + } + + public func transcribe(audioFilePath: String) async throws(STTRepositoryError) -> Transcript { + guard !Task.isCancelled else { throw .cancelled } + + do { + let audioURL = storageService.absoluteURL(for: audioFilePath) + let result: [TranscriptionResult] = try await dataSource.transcribe(audioPath: audioURL) + + // Whisper 메모리 해제 + await dataSource.clearCache() + + let sections = result.flatMap(\.segments).map { segment in + TranscriptSection( + timestamp: TimeInterval(segment.start), + text: segment.text.trimmingCharacters(in: .whitespacesAndNewlines) + ) + }.filter { !$0.text.isEmpty } + + if !sections.isEmpty { + return Transcript(sections: sections) + } + + let text = result + .map(\.text) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { throw STTRepositoryError.transcribeFailed } + return Transcript(sections: [TranscriptSection(timestamp: 0, text: text)]) + } catch let error as STTRepositoryError { + await dataSource.clearCache() + throw error + } catch { + await dataSource.clearCache() + throw .unknown(error) + } + } + + public nonisolated func checkSTTPermission() -> PermissionStatus { + switch SFSpeechRecognizer.authorizationStatus() { + case .authorized: + return .authorized + case .denied, .restricted: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .denied + } + } + + public func requestSTTPermission() async throws(STTPermissionRepositoryError) -> PermissionStatus { + if Task.isCancelled { throw .cancelled } + + let status = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + + switch status { + case .authorized: + return .authorized + case .denied, .restricted: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .denied + } + } +} diff --git a/Data/Sources/Repositories/VoiceNotes/MLXSummaryResult.swift b/Data/Sources/Repositories/VoiceNotes/MLXSummaryResult.swift new file mode 100644 index 00000000..e4a119d9 --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/MLXSummaryResult.swift @@ -0,0 +1,6 @@ +import Foundation + +struct MLXSummaryResult: Codable { + let keywords: [String] + let keyPoints: [String] +} diff --git a/Data/Sources/Repositories/VoiceNotes/SummaryGenerationResult.swift b/Data/Sources/Repositories/VoiceNotes/SummaryGenerationResult.swift new file mode 100644 index 00000000..0f5abf20 --- /dev/null +++ b/Data/Sources/Repositories/VoiceNotes/SummaryGenerationResult.swift @@ -0,0 +1,11 @@ +#if canImport(FoundationModels) + import FoundationModels + + @Generable + struct SummaryGenerationResult { + let keywords: [String] + + @Guide(description: "핵심 포인트 목록", .minimumCount(1), .maximumCount(3)) + let keyPoints: [String] + } +#endif diff --git a/Data/Sources/Repositories/VoiceRecords/DefaultVoiceRecordPlaybackRepository.swift b/Data/Sources/Repositories/VoiceRecords/DefaultVoiceRecordPlaybackRepository.swift new file mode 100644 index 00000000..77dee00d --- /dev/null +++ b/Data/Sources/Repositories/VoiceRecords/DefaultVoiceRecordPlaybackRepository.swift @@ -0,0 +1,202 @@ +import AVFoundation +import Core +import Domain +import Foundation + +@MainActor +public final class DefaultVoiceRecordPlaybackRepository: NSObject, VoiceRecordPlaybackRepository { + private let storageService: any StorageService + + // AudioPlaybackPlayerService에서 가져온 프로퍼티들 + private var player: AVAudioPlayer? + private var progressTask: Task? + private var playbackStatus: AudioPlaybackState.Status = .idle + private var duration: TimeInterval = 0 + private let _playback = AsyncStream + .makeStream(bufferingPolicy: .bufferingNewest(Policy.playbackStateStreamBufferLimit)) + private var isSessionActive = false + + private var playbackStream: AsyncStream { + _playback.stream + } + + private var playbackContinuation: AsyncStream.Continuation { + _playback.continuation + } + + public init(storageService: any StorageService) { + self.storageService = storageService + super.init() + } + + // MARK: - VoiceRecordPlaybackRepository + + public func prepare(audioFilePath: String) throws(VoiceRecordPlaybackRepositoryError) + -> AsyncStream + { + let absoluteURL = storageService.absoluteURL(for: audioFilePath) + + // 이전 세션 정리 + player?.stop() + stopProgressTask() + player?.delegate = nil + player = nil + deactivateSessionIfNeeded() + + let player: AVAudioPlayer + do { + player = try AVAudioPlayer(contentsOf: absoluteURL) + } catch { + AppLogger.error(error) + throw .prepareFailed + } + + player.delegate = self + guard player.prepareToPlay() else { throw .prepareFailed } + + self.player = player + duration = player.duration + updateState(status: .idle, currentTime: 0, duration: player.duration) + return playbackStream + } + + public func play() throws(VoiceRecordPlaybackRepositoryError) { + guard let player else { throw .notPrepared } + + if player.currentTime >= player.duration { + player.currentTime = 0 + } + + do { + try activateSession() + } catch { + throw .playFailed + } + + guard player.play() else { throw .playFailed } + + startProgressTask() + updateState(status: .playing, currentTime: player.currentTime, duration: player.duration) + } + + public func pause() throws(VoiceRecordPlaybackRepositoryError) { + guard let player else { throw .notPrepared } + guard player.isPlaying else { throw .pauseFailed } + + player.pause() + stopProgressTask() + updateState(status: .paused, currentTime: player.currentTime, duration: player.duration) + } + + public func seek(to time: TimeInterval) throws(VoiceRecordPlaybackRepositoryError) { + guard let player else { throw .notPrepared } + + let clampedTime = min(max(0, time), player.duration) + player.currentTime = clampedTime + + let status: AudioPlaybackState.Status = { + if player.isPlaying { return .playing } + if player.duration > 0, clampedTime >= player.duration { return .finished } + if playbackStatus == .idle, clampedTime == 0 { return .idle } + return .paused + }() + + updateState(status: status, currentTime: clampedTime, duration: player.duration) + } + + public func stop() throws(VoiceRecordPlaybackRepositoryError) { + player?.stop() + player?.currentTime = 0 + stopProgressTask() + player?.delegate = nil + player = nil + updateState(status: .idle, currentTime: 0, duration: duration) + deactivateSessionIfNeeded() + } +} + +// MARK: - AVAudioPlayerDelegate + +extension DefaultVoiceRecordPlaybackRepository: AVAudioPlayerDelegate { + public nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + let duration = player.duration + Task { @MainActor [weak self] in + guard let self, flag else { return } + stopProgressTask() + updateState(status: .finished, currentTime: duration, duration: duration) + deactivateSessionIfNeeded() + } + } + + public nonisolated func audioPlayerBeginInterruption(_ player: AVAudioPlayer) { + Task { @MainActor [weak self] in + guard let self, let player = self.player else { return } + player.pause() + stopProgressTask() + updateState(status: .paused, currentTime: player.currentTime, duration: player.duration) + } + } + + public nonisolated func audioPlayerEndInterruption(_ player: AVAudioPlayer, withOptions flags: Int) { + let shouldResume = AVAudioSession.InterruptionOptions(rawValue: UInt(flags)).contains(.shouldResume) + Task { @MainActor [weak self] in + guard let self, let player = self.player else { return } + if shouldResume { + try? activateSession() + _ = player.play() + startProgressTask() + updateState(status: .playing, currentTime: player.currentTime, duration: player.duration) + } else { + updateState(status: .paused, currentTime: player.currentTime, duration: player.duration) + } + } + } +} + +// MARK: - Private Helpers + +private extension DefaultVoiceRecordPlaybackRepository { + func activateSession() throws { + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .default) + try session.setActive(true) + isSessionActive = true + } catch { + throw error + } + } + + func deactivateSessionIfNeeded() { + guard isSessionActive else { return } + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + isSessionActive = false + } catch { + AppLogger.error(error) + } + } + + func startProgressTask() { + progressTask?.cancel() + progressTask = Task { [weak self] in + while !Task.isCancelled { + guard let self, let player else { return } + if player.isPlaying { + updateState(status: .playing, currentTime: player.currentTime, duration: player.duration) + } + try? await Task.sleep(nanoseconds: Policy.playbackProgressUpdateInterval) + } + } + } + + func stopProgressTask() { + progressTask?.cancel() + progressTask = nil + } + + func updateState(status: AudioPlaybackState.Status, currentTime: TimeInterval, duration: TimeInterval) { + playbackStatus = status + playbackContinuation.yield(AudioPlaybackState(status: status, currentTime: currentTime, duration: duration)) + } +} diff --git a/Data/Sources/Repositories/VoiceRecords/DefaultVoiceRecordRepository.swift b/Data/Sources/Repositories/VoiceRecords/DefaultVoiceRecordRepository.swift new file mode 100644 index 00000000..2cebe789 --- /dev/null +++ b/Data/Sources/Repositories/VoiceRecords/DefaultVoiceRecordRepository.swift @@ -0,0 +1,280 @@ +import AVFoundation +import Core +import Domain +import Foundation + +/// 오디오 녹음을 담당하는 리포지토리 기본 구현체. +public actor DefaultVoiceRecordRepository: VoiceRecordRepository { + private let storageService: any StorageService + + // AudioService에서 가져온 프로퍼티들 + private let waveformUpdateInterval: UInt64 = 100_000_000 + private var recorder: AVAudioRecorder? + private var recordingFilePath: URL? + private var recordingCreatedAt: Date? + private var waveformContinuation: AsyncStream.Continuation? + private var waveformTask: Task? + private var recorderDelegate: RecorderDelegate? + private var isPaused = false + private var isFinishing = false + + public init(storageService: any StorageService) { + self.storageService = storageService + } + + // MARK: - VoiceRecordRepository (Permission) + + public nonisolated func checkMicrophonePermission() -> PermissionStatus { + switch AVAudioApplication.shared.recordPermission { + case .granted: return .authorized + case .denied: return .denied + case .undetermined: return .notDetermined + @unknown default: return .denied + } + } + + public func requestMicrophonePermission() async throws(VoiceRecordRepositoryError) -> PermissionStatus { + if Task.isCancelled { throw .cancelled } + let granted = await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + return granted ? .authorized : .denied + } + + // MARK: - VoiceRecordRepository (Recording Control) + + public func startRecording() async throws(VoiceRecordRepositoryError) -> AsyncStream { + if Task.isCancelled { throw .cancelled } + guard recorder == nil else { throw .alreadyRecording } + + let fileName = "\(Date.now.yyyyMMddHHmmssString).m4a" + let tempURL: URL + do { + tempURL = try storageService.generateTemporaryURL(fileName: fileName) + } catch { + throw .startFailed + } + + try activateSession() + + let recorder: AVAudioRecorder + do { + recorder = try makeRecorder(filePath: tempURL) + } catch { + deactivateSession() + throw .startFailed + } + + let delegate = RecorderDelegate { [weak self] successfully in + Task { await self?.handleRecordingFinished(successfully: successfully) } + } + recorder.delegate = delegate + + let (waveformStream, waveformContinuation) = AsyncStream.makeStream( + of: Waveform.self, + bufferingPolicy: .bufferingNewest(Policy.waveformStreamBufferLimit) + ) + waveformContinuation.onTermination = { [weak self] _ in + Task { await self?.handleWaveformTermination() } + } + + guard recorder.record() else { + waveformContinuation.finish() + deactivateSession() + throw .startFailed + } + + recorderDelegate = delegate + self.recorder = recorder + recordingFilePath = tempURL + recordingCreatedAt = Date.now + self.waveformContinuation = waveformContinuation + waveformTask = Task { [weak self] in await self?.streamWaveform() } + isPaused = false + isFinishing = false + + AppLogger.info("녹음 시작: \(tempURL.lastPathComponent)") + return waveformStream + } + + public func pauseRecording() async throws(VoiceRecordRepositoryError) { + guard let recorder, isPaused == false, recorder.isRecording else { throw .pauseFailed } + recorder.pause() + isPaused = true + AppLogger.info("녹음 일시정지") + } + + public func resumeRecording() async throws(VoiceRecordRepositoryError) { + guard let recorder, isPaused else { throw .notPaused } + try activateSession() + guard recorder.record() else { throw .resumeFailed } + isPaused = false + AppLogger.info("녹음 재개") + } + + public func cancelRecording() async throws(VoiceRecordRepositoryError) { + let currentURL = recordingFilePath + await stopRecordingSession() + if let currentURL { + try? storageService.delete(fileURL: currentURL) + } + } + + public func finishRecording() async throws(VoiceRecordRepositoryError) -> VoiceRecord { + guard let recorder, let recordingFilePath, let recordingCreatedAt else { + throw .notRecording + } + + isFinishing = true + closeWaveformStream() + recorder.stop() + await stopWaveformTask() + + let recorded: RecordedAudio + do { + let audioFile = try AVAudioFile(forReading: recordingFilePath) + let duration = audioFile.processingFormat.sampleRate > 0 + ? Double(audioFile.length) / audioFile.processingFormat.sampleRate + : Date.now.timeIntervalSince(recordingCreatedAt) + + recorded = RecordedAudio( + createdAt: recordingCreatedAt, + audioFilePath: recordingFilePath, + duration: duration + ) + } catch { + AppLogger.error(error) + await stopRecordingSession() + throw .encodingFailed + } + + clearRecordingSession() + deactivateSession() + + // Storage 이동 로직 (기존 Repository에 있던 것) + do { + let normalizedExtension = recorded.audioFilePath.pathExtension.trimmingCharacters( + in: CharacterSet(charactersIn: ".") + ) + let fileName = "\(recorded.createdAt.yyyyMMddHHmmssString).\(normalizedExtension)" + let relativePath = try storageService.moveFile( + from: recorded.audioFilePath, + toDirectory: "VoiceRecords", + fileName: fileName + ) + + return VoiceRecord( + createdAt: recorded.createdAt, + audioFilePath: relativePath, + duration: recorded.duration + ) + } catch { + AppLogger.error(error) + throw .finishFailed + } + } + + // MARK: - Private (Session & Recorder Helpers) + + private func activateSession() throws(VoiceRecordRepositoryError) { + do { + try AVAudioSession.sharedInstance().setCategory(.record, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + throw .startFailed + } + } + + private func deactivateSession() { + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private func makeRecorder(filePath: URL) throws -> AVAudioRecorder { + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 64000 + ] + let recorder = try AVAudioRecorder(url: filePath, settings: settings) + recorder.isMeteringEnabled = true + recorder.prepareToRecord() + return recorder + } + + private func streamWaveform() async { + while !Task.isCancelled { + guard let recorder, !isFinishing else { return } + if isPaused { + try? await Task.sleep(nanoseconds: waveformUpdateInterval) + continue + } + if recorder.isRecording { + recorder.updateMeters() + let averagePower = recorder.averagePower(forChannel: 0) + let normalizedPower = max(0, min(1, pow(10, averagePower / 20))) + let amplitudes = Array(repeating: normalizedPower, count: Policy.waveformSamplesPerBuffer) + waveformContinuation?.yield(Waveform(amplitudes: amplitudes)) + } + try? await Task.sleep(nanoseconds: waveformUpdateInterval) + } + } + + private func handleRecordingFinished(successfully flag: Bool) async { + guard recorder != nil, !isFinishing else { return } + await stopRecordingSession() + } + + private func handleWaveformTermination() async { + guard !isFinishing else { return } + closeWaveformStream() + await stopWaveformTask() + } + + private func stopRecordingSession() async { + isFinishing = true + closeWaveformStream() + recorder?.stop() + await stopWaveformTask() + clearRecordingSession() + deactivateSession() + } + + private func closeWaveformStream() { + waveformContinuation?.finish() + waveformContinuation = nil + } + + private func stopWaveformTask() async { + let task = waveformTask + waveformTask = nil + task?.cancel() + _ = await task?.result + } + + private func clearRecordingSession() { + recorder?.delegate = nil + recorder = nil + recorderDelegate = nil + recordingFilePath = nil + recordingCreatedAt = nil + isPaused = false + isFinishing = false + } +} + +// MARK: - RecorderDelegate (Helper) + +private final class RecorderDelegate: NSObject, AVAudioRecorderDelegate, @unchecked Sendable { + let onDidFinishRecording: @Sendable (Bool) -> Void + init(onDidFinishRecording: @escaping @Sendable (Bool) -> Void) { + self.onDidFinishRecording = onDidFinishRecording + super.init() + } + + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + onDidFinishRecording(flag) + } +} diff --git a/Data/Sources/Repositories/VoiceRecords/RecordedAudio.swift b/Data/Sources/Repositories/VoiceRecords/RecordedAudio.swift new file mode 100644 index 00000000..3b478dad --- /dev/null +++ b/Data/Sources/Repositories/VoiceRecords/RecordedAudio.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct RecordedAudio: Sendable { + public let createdAt: Date + public let audioFilePath: URL + public let duration: Double + + public init( + createdAt: Date, + audioFilePath: URL, + duration: Double + ) { + self.createdAt = createdAt + self.audioFilePath = audioFilePath + self.duration = duration + } +} diff --git a/Domain/Project.swift b/Domain/Project.swift new file mode 100644 index 00000000..627bab45 --- /dev/null +++ b/Domain/Project.swift @@ -0,0 +1,89 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +private let domainScheme = Scheme.scheme( + name: "Domain", + shared: true, + buildAction: .buildAction( + targets: [.target("Domain")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("DomainTests"), parallelization: .disabled) + ]) +) + +private let domainTestsScheme = Scheme.scheme( + name: "DomainTests", + shared: true, + buildAction: .buildAction( + targets: [.target("DomainTests")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("DomainTests"), parallelization: .disabled) + ]) +) + +private let domainTarget = Target.target( + name: "Domain", + destinations: .iOS, + product: .framework, + bundleId: "\(bundleId).Domain", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Sources/**/*.swift"], + dependencies: [ + .project(target: "Core", path: "../Core") + ] +) + +private let domainTestingTarget = Target.target( + name: "DomainTesting", + destinations: .iOS, + product: .framework, + bundleId: "\(bundleId).DomainTesting", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: [ + "Testing/Interfaces/Mocks/**/*.swift", + "Testing/Entities/Stubs/**/*.swift" + ], + dependencies: [ + .target(name: "Domain"), + .xctest + ] +) + +private let domainTestsTarget = Target.target( + name: "DomainTests", + destinations: .iOS, + product: .unitTests, + bundleId: "\(bundleId).DomainTests", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Tests/UseCases/**/*.swift"], + dependencies: [ + .target(name: "Domain"), + .target(name: "DomainTesting"), + .xctest + ] +) + +let project = Project( + name: "Domain", + options: .options( + defaultKnownRegions: ["ko", "en"], + developmentRegion: "ko" + ), + settings: settings, + targets: [ + domainTarget, + domainTestingTarget, + domainTestsTarget + ], + schemes: [ + domainScheme, + domainTestsScheme + ] +) diff --git a/Domain/Sources/Entities/AudioFileFormat.swift b/Domain/Sources/Entities/AudioFileFormat.swift new file mode 100644 index 00000000..0535bc3f --- /dev/null +++ b/Domain/Sources/Entities/AudioFileFormat.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum AudioFileFormat: String, CaseIterable, Sendable { + case m4a + case wav + case mp3 + case caf + case aac + case aiff + case aif + + public init?(extension: String) { + self.init(rawValue: `extension`.lowercased()) + } +} diff --git a/Domain/Sources/Entities/AudioPlaybackState.swift b/Domain/Sources/Entities/AudioPlaybackState.swift new file mode 100644 index 00000000..2e7447ed --- /dev/null +++ b/Domain/Sources/Entities/AudioPlaybackState.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct AudioPlaybackState: Sendable, Equatable { + public enum Status: Sendable, Equatable { + case idle + case playing + case paused + case finished + } + + public let status: Status + public let currentTime: TimeInterval + public let duration: TimeInterval + + public init( + status: Status, + currentTime: TimeInterval, + duration: TimeInterval + ) { + self.status = status + self.currentTime = currentTime + self.duration = duration + } +} diff --git a/Domain/Sources/Entities/AudioToSummaryResult.swift b/Domain/Sources/Entities/AudioToSummaryResult.swift new file mode 100644 index 00000000..61b26685 --- /dev/null +++ b/Domain/Sources/Entities/AudioToSummaryResult.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct AudioToSummaryResult: Sendable { + public let transcript: Transcript + public let keywords: [Keyword] + public let summary: Summary + + public init( + transcript: Transcript, + keywords: [Keyword], + summary: Summary + ) { + self.transcript = transcript + self.keywords = keywords + self.summary = summary + } +} diff --git a/Domain/Sources/Entities/ChaGokModelSupport.swift b/Domain/Sources/Entities/ChaGokModelSupport.swift new file mode 100644 index 00000000..c79797b8 --- /dev/null +++ b/Domain/Sources/Entities/ChaGokModelSupport.swift @@ -0,0 +1,59 @@ +import Foundation + +/// 현재 사용자에게 지원되는 LLM 모델 추천 객채 +public struct ChaGokModelSupport: Hashable, Sendable { + let ramSizeGB: Int + var isProUser: Bool + + /// RAM 사양에 따라 결정되는 모델 + public var model: ChaGokModel { + if ramSizeGB >= 6 { + return .gemma4_e2b_4bit + } else { + return .none + } + } + + public init(ramSizeGB: Int, isProUser: Bool = false) { + self.ramSizeGB = ramSizeGB + self.isProUser = isProUser + } + + /// 현재 기기 정보를 바로 가져오는 속성 (에러 수정됨) + public static var current: ChaGokModelSupport { + let ram = Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024 * 1024)) + return ChaGokModelSupport(ramSizeGB: ram) + } +} + +/// 차곡에서 사용하는 OnDevice-AI LLM 모델입니다. +public enum ChaGokModel: Equatable, Sendable, CaseIterable { + case none // OnDevice Model 제공 X + case whisper + case gemma4_e2b_4bit + + /// none을 제외한 모델을 List 화 합니다. + public static var models: [ChaGokModel] { + Array(allCases.filter { $0 != .none }) + } +} + +/// 차곡 - 설정에서 사용자가 현재 모델의 상태를 나타냅니다. +public struct ChaGokModelState: Hashable, Sendable { + public let title: String + public let subTitle: String + public let model: ChaGokModel + public var status: OnDeviceStatus + + public init( + title: String, + subTitle: String, + model: ChaGokModel, + status: OnDeviceStatus = .init(storage: .notDownloaded) + ) { + self.title = title + self.subTitle = subTitle + self.model = model + self.status = status + } +} diff --git a/Domain/Sources/Entities/ContentItem.swift b/Domain/Sources/Entities/ContentItem.swift new file mode 100644 index 00000000..ecad3f24 --- /dev/null +++ b/Domain/Sources/Entities/ContentItem.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum ContentItem: Hashable, Sendable { + case folder(Folder) + case voiceNote(VoiceNote) + + public var id: UUID { + switch self { + case .folder(let folder): return folder.id + case .voiceNote(let voiceNote): return voiceNote.id + } + } + + public var createdAt: Date { + switch self { + case .folder(let folder): return folder.createdAt + case .voiceNote(let voiceNote): return voiceNote.createdAt + } + } + + /// 폴더는 updatedAt 개념이 없어 createdAt을 대리값으로 사용합니다. + public var updatedAt: Date { + switch self { + case .folder(let folder): return folder.createdAt + case .voiceNote(let voiceNote): return voiceNote.updatedAt + } + } + + public var deletedAt: Date? { + switch self { + case .folder(let folder): return folder.deletedAt + case .voiceNote(let voiceNote): return voiceNote.deletedAt + } + } +} diff --git a/Domain/Sources/Entities/Folder.swift b/Domain/Sources/Entities/Folder.swift new file mode 100644 index 00000000..e19b5e23 --- /dev/null +++ b/Domain/Sources/Entities/Folder.swift @@ -0,0 +1,31 @@ +import Foundation + +public struct Folder: Sendable, Identifiable, Hashable { + public let id: UUID + public var name: String + public let createdAt: Date + /// 폴더에 속한 살아있는 보이스 노트 ID 목록 (휴지통 노트 제외). + public let voiceNoteIDs: [UUID] + public let kind: FolderKind + public var deletedAt: Date? + /// 부모 폴더 ID. 일반 root 폴더는 `nil`, 휴지통 안의 폴더는 휴지통 폴더 ID. + public var parentID: UUID? + + public init( + id: UUID = UUID(), + name: String, + createdAt: Date = Date.now, + voiceNoteIDs: [UUID] = [], + kind: FolderKind = .custom, + deletedAt: Date? = nil, + parentID: UUID? = nil + ) { + self.id = id + self.name = name + self.createdAt = createdAt + self.voiceNoteIDs = voiceNoteIDs + self.kind = kind + self.deletedAt = deletedAt + self.parentID = parentID + } +} diff --git a/Domain/Sources/Entities/FolderKind.swift b/Domain/Sources/Entities/FolderKind.swift new file mode 100644 index 00000000..1991f3b7 --- /dev/null +++ b/Domain/Sources/Entities/FolderKind.swift @@ -0,0 +1,11 @@ +import Foundation + +/// 폴더의 종류를 표현하는 enum. +public enum FolderKind: String, Sendable, Hashable, CaseIterable { + /// 시스템이 생성하는 기본 폴더. 사용자 삭제/이름 변경 불가. + case `default` + /// 사용자가 직접 만든 폴더. + case custom + /// 휴지통(시스템 폴더). 휴지통 통합 시 활용 예정. + case trash +} diff --git a/Domain/Sources/Entities/Keyword.swift b/Domain/Sources/Entities/Keyword.swift new file mode 100644 index 00000000..a1a69458 --- /dev/null +++ b/Domain/Sources/Entities/Keyword.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct Keyword: Sendable, Identifiable, Hashable { + public let id: UUID + public let noteID: UUID + public let word: String + + public init( + id: UUID = UUID(), + noteID: UUID, + word: String + ) { + self.id = id + self.noteID = noteID + self.word = word + } +} diff --git a/Domain/Sources/Entities/Language.swift b/Domain/Sources/Entities/Language.swift new file mode 100644 index 00000000..67ea8ca4 --- /dev/null +++ b/Domain/Sources/Entities/Language.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum Language: String, CaseIterable, Sendable { + case ko = "Korean" + case en = "English" + + /// BCP-47 locale identifier. `Locale` / `SFSpeechRecognizer` 등 Foundation·플랫폼 API에 전달하는 용도. + public var localeIdentifier: String { + switch self { + case .ko: return "ko-KR" + case .en: return "en-US" + } + } +} diff --git a/Domain/Sources/Entities/OnDeviceStatus.swift b/Domain/Sources/Entities/OnDeviceStatus.swift new file mode 100644 index 00000000..b156c940 --- /dev/null +++ b/Domain/Sources/Entities/OnDeviceStatus.swift @@ -0,0 +1,27 @@ +import Foundation + +/// on-device 리소스의 현재 상태를 표현하는 도메인 순수 객체. +/// +/// 저장 상태(`storage`)와 메모리 적재 상태(`runtime`)를 분리해서 표현합니다. +public struct OnDeviceStatus: Hashable, Sendable { + /// 디스크 또는 캐시 상의 저장 상태입니다. + public var storage: StorageState + + public init( + storage: StorageState = .notDownloaded + ) { + self.storage = storage + } + + /// 다운로드 및 삭제처럼, 모델 파일의 보관 상태를 나타냅니다. + public enum StorageState: Sendable, Hashable { + /// 아직 내려받지 않은 상태입니다. + case notDownloaded + /// 다운로드가 진행 중인 상태입니다. + case downloading(progress: Double) + /// 로컬에 파일이 준비된 상태입니다. + case downloaded + /// 저장 단계에서 실패한 상태입니다. + case failed + } +} diff --git a/Domain/Sources/Entities/PermissionStatus.swift b/Domain/Sources/Entities/PermissionStatus.swift new file mode 100644 index 00000000..fdeace29 --- /dev/null +++ b/Domain/Sources/Entities/PermissionStatus.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum PermissionStatus: Sendable { + case notDetermined + case authorized + case denied +} diff --git a/Domain/Sources/Entities/StorageInfo.swift b/Domain/Sources/Entities/StorageInfo.swift new file mode 100644 index 00000000..5754aa25 --- /dev/null +++ b/Domain/Sources/Entities/StorageInfo.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct StorageInfo: Sendable, Equatable { + public let appUsedBytes: Int64 + public let deviceTotalBytes: Int64 + public let deviceUsedBytes: Int64 + + public init( + appUsedBytes: Int64, + deviceTotalBytes: Int64, + deviceUsedBytes: Int64 + ) { + self.appUsedBytes = appUsedBytes + self.deviceTotalBytes = deviceTotalBytes + self.deviceUsedBytes = deviceUsedBytes + } +} diff --git a/Domain/Sources/Entities/Summary.swift b/Domain/Sources/Entities/Summary.swift new file mode 100644 index 00000000..ea52865d --- /dev/null +++ b/Domain/Sources/Entities/Summary.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct Summary: Sendable, Identifiable, Hashable { + public let id: UUID + public let createdAt: Date + public let text: String + + public init( + id: UUID = UUID(), + createdAt: Date = Date.now, + text: String + ) { + self.id = id + self.createdAt = createdAt + self.text = text + } +} diff --git a/Domain/Sources/Entities/Transcript.swift b/Domain/Sources/Entities/Transcript.swift new file mode 100644 index 00000000..eef65779 --- /dev/null +++ b/Domain/Sources/Entities/Transcript.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct Transcript: Sendable, Identifiable, Hashable { + public let id: UUID + public let createdAt: Date + public let updatedAt: Date + /// 타임스탬프 기반으로 묶인 스크립트 섹션 + public let sections: [TranscriptSection] + + public init( + id: UUID = UUID(), + createdAt: Date = Date.now, + updatedAt: Date? = nil, + sections: [TranscriptSection] = [] + ) { + self.id = id + self.createdAt = createdAt + self.updatedAt = updatedAt ?? createdAt + self.sections = sections + } +} diff --git a/Domain/Sources/Entities/TranscriptSection.swift b/Domain/Sources/Entities/TranscriptSection.swift new file mode 100644 index 00000000..a36410ee --- /dev/null +++ b/Domain/Sources/Entities/TranscriptSection.swift @@ -0,0 +1,14 @@ +import Foundation + +/// 전사 섹션 — 시작 타임스탬프를 기준으로 묶인 스크립트 문단 +public struct TranscriptSection: Sendable, Hashable, Codable { + /// 섹션 시작 시간 (초) + public let timestamp: TimeInterval + /// 섹션 본문 텍스트 + public let text: String + + public init(timestamp: TimeInterval, text: String) { + self.timestamp = timestamp + self.text = text + } +} diff --git a/Domain/Sources/Entities/VoiceNote.swift b/Domain/Sources/Entities/VoiceNote.swift new file mode 100644 index 00000000..6e931e9c --- /dev/null +++ b/Domain/Sources/Entities/VoiceNote.swift @@ -0,0 +1,82 @@ +import Foundation + +public enum AnalysisState: String, Sendable, Hashable { + case pending + case transcribing + case transcriptionFailed + case transcribed + case summarizing + case regenerating + case completed + case summarizationFailed + + public enum BindingKey { + case progress + case success + case failed + } + + public var bindingValue: BindingKey { + switch self { + case .pending, .transcribing, .transcribed, .regenerating, .summarizing: + .progress + case .completed: + .success + case .transcriptionFailed, .summarizationFailed: + .failed + } + } +} + +public struct VoiceNote: Sendable, Identifiable, Hashable { + public let id: UUID + public var title: String + public let createdAt: Date + public var updatedAt: Date + public var folderID: UUID + public let voiceRecord: VoiceRecord + public let keywords: [Keyword] + public var transcript: Transcript? + public var summary: Summary? + public var deletedAt: Date? + /// 휴지통에 단독 진입했을 때 복원 destination이 되는 원본 폴더 ID. 단독 진입 외에는 `nil`. + /// 폴더 cascade 삭제는 노트 자체를 옮기지 않으므로 본 필드를 세팅하지 않는다. + public var originalFolderID: UUID? + public var analysisState: AnalysisState + + public init( + id: UUID = UUID(), + title: String, + createdAt: Date = Date.now, + updatedAt: Date = Date.now, + folderID: UUID, + voiceRecord: VoiceRecord, + keywords: [Keyword] = [], + transcript: Transcript? = nil, + summary: Summary? = nil, + deletedAt: Date? = nil, + originalFolderID: UUID? = nil, + analysisState: AnalysisState + ) { + self.id = id + self.title = title + self.createdAt = createdAt + self.updatedAt = updatedAt + self.folderID = folderID + self.voiceRecord = voiceRecord + self.keywords = keywords + self.transcript = transcript + self.summary = summary + self.deletedAt = deletedAt + self.originalFolderID = originalFolderID + self.analysisState = analysisState + } +} + +public extension VoiceNote { + /// 요약 생성 이후 스크립트가 수정되어 요약이 최신 상태가 아닌지 여부. + var isSummaryOutdated: Bool { + guard let summary, let transcript else { return false } + return summary.createdAt < transcript.updatedAt + } +} diff --git a/Domain/Sources/Entities/VoiceRecord.swift b/Domain/Sources/Entities/VoiceRecord.swift new file mode 100644 index 00000000..73dfa876 --- /dev/null +++ b/Domain/Sources/Entities/VoiceRecord.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct VoiceRecord: Sendable, Identifiable, Hashable { + public let id: UUID + public let createdAt: Date + public let audioFilePath: String + public let duration: Double + + public init( + id: UUID = UUID(), + createdAt: Date = Date.now, + audioFilePath: String, + duration: Double + ) { + self.id = id + self.createdAt = createdAt + self.audioFilePath = audioFilePath + self.duration = duration + } +} diff --git a/Domain/Sources/Entities/Waveform.swift b/Domain/Sources/Entities/Waveform.swift new file mode 100644 index 00000000..7825876e --- /dev/null +++ b/Domain/Sources/Entities/Waveform.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct Waveform: Sendable { + public let amplitudes: [Float] + + public init(amplitudes: [Float]) { + self.amplitudes = amplitudes + } +} diff --git a/Domain/Sources/Errors/Authority/Repositories/STTPermissionRepositoryError.swift b/Domain/Sources/Errors/Authority/Repositories/STTPermissionRepositoryError.swift new file mode 100644 index 00000000..9d111259 --- /dev/null +++ b/Domain/Sources/Errors/Authority/Repositories/STTPermissionRepositoryError.swift @@ -0,0 +1,14 @@ +import Foundation + +/// STT 권한 리포지토리 에러 +public enum STTPermissionRepositoryError: LocalizedError, Sendable { + case cancelled + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: return nil + case .unknown(let error): return error.localizedDescription + } + } +} diff --git a/Domain/Sources/Errors/Folders/Repositories/FolderRepositoryError.swift b/Domain/Sources/Errors/Folders/Repositories/FolderRepositoryError.swift new file mode 100644 index 00000000..fa2d7828 --- /dev/null +++ b/Domain/Sources/Errors/Folders/Repositories/FolderRepositoryError.swift @@ -0,0 +1,37 @@ +import Foundation + +public enum FolderRepositoryError: LocalizedError, Sendable { + /// 작업 취소의 경우 + case cancelled + /// 폴더를 찾을 수 없는 경우 (조회, 수정 시 발생) + case notFound + /// 동일한 이름의 폴더가 이미 존재하는 경우 (생성, 수정 시 발생) + case duplicateName + /// 폴더 생성이 실패한 경우 + case createFailed + /// 폴더 조회가 실패한 경우 + case fetchFailed + /// 폴더 업데이트가 실패한 경우 + case updateFailed + /// 기타 알 수 없는 에러 + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: + return nil + case .notFound: + return "해당 폴더를 찾을 수 없습니다." + case .duplicateName: + return "이미 동일한 이름의 폴더가 존재합니다." + case .createFailed: + return "폴더 생성에 실패했습니다." + case .fetchFailed: + return "폴더 목록을 불러오는데 실패했습니다." + case .updateFailed: + return "폴더 정보를 수정하는데 실패했습니다." + case .unknown(let error): + return error.localizedDescription + } + } +} diff --git a/Domain/Sources/Errors/Folders/UseCases/FolderUseCaseError.swift b/Domain/Sources/Errors/Folders/UseCases/FolderUseCaseError.swift new file mode 100644 index 00000000..3873a0f6 --- /dev/null +++ b/Domain/Sources/Errors/Folders/UseCases/FolderUseCaseError.swift @@ -0,0 +1,68 @@ +import Foundation + +public enum FolderUseCaseError: LocalizedError, Sendable { + /// 작업 취소의 경우 + case cancelled + /// 유효하지 않은 이름의 경우 + case invalidName + /// 유효하지 않은 글자 수의 경우 + case invalidLengthName + /// 동일한 이름의 폴더가 이미 존재하는 경우 + case duplicateName + /// 기본 폴더 등 사용할 수 없는 예약된 이름인 경우 + case reservedName + /// 폴더를 찾을 수 없는 경우 + case notFound + /// 폴더 생성이 실패한 경우 + case createFailed + /// 폴더 조회가 실패한 경우 + case fetchFailed + /// 폴더 업데이트가 실패한 경우 + case updateFailed + /// 기타 알 수 없는 에러 + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: + return nil + case .invalidName: + return "폴더 이름을 한 글자 이상 입력해 주세요." + case .invalidLengthName: + return "폴더 이름은 \(Policy.maxNameLength)자 이내로 입력해주세요." + case .duplicateName: + return "이미 동일한 이름의 폴더가 존재합니다." + case .reservedName: + return "해당 이름은 시스템 기능 전용이므로 사용할 수 없습니다." + case .notFound: + return "해당 폴더를 찾을 수 없습니다." + case .createFailed: + return "폴더 생성에 실패했습니다." + case .fetchFailed: + return "폴더 목록을 불러오는데 실패했습니다." + case .updateFailed: + return "폴더 정보를 수정하는데 실패했습니다." + case .unknown(let error): + return error.localizedDescription + } + } + + init(_ error: FolderRepositoryError) { + switch error { + case .cancelled: + self = .cancelled + case .duplicateName: + self = .duplicateName + case .notFound: + self = .notFound + case .createFailed: + self = .createFailed + case .fetchFailed: + self = .fetchFailed + case .updateFailed: + self = .updateFailed + case .unknown(let e): + self = .unknown(e) + } + } +} diff --git a/Domain/Sources/Errors/MLXSupport/AvailableModelSupportRepositoryError.swift b/Domain/Sources/Errors/MLXSupport/AvailableModelSupportRepositoryError.swift new file mode 100644 index 00000000..af261129 --- /dev/null +++ b/Domain/Sources/Errors/MLXSupport/AvailableModelSupportRepositoryError.swift @@ -0,0 +1,18 @@ +import Foundation + +public enum AvailableModelSupportRepositoryError: LocalizedError, Sendable { + /// Task 취소 + case cancelled + /// 모델을 찾을 수 없음 + case notFoundModel + /// unknown + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: return nil + case .notFoundModel: return "설치 가능한 모델이 없습니다" + case .unknown(let error): return error.localizedDescription + } + } +} diff --git a/Domain/Sources/Errors/OnDevice/DeleteOnDeviceRepositoryError.swift b/Domain/Sources/Errors/OnDevice/DeleteOnDeviceRepositoryError.swift new file mode 100644 index 00000000..e1150480 --- /dev/null +++ b/Domain/Sources/Errors/OnDevice/DeleteOnDeviceRepositoryError.swift @@ -0,0 +1,22 @@ +import Foundation + +public enum DeleteOnDeviceRepositoryError: LocalizedError, Sendable { + /// Task 취소 + case cancelled + case deleteWhisperFailed + case deleteMLXFailed + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: + return "취소되었습니다" + case .deleteWhisperFailed: + return "whisper 모델 삭제를 실패하였습니다" + case .deleteMLXFailed: + return "MLX 모델 삭제를 실패하였습니다" + case .unknown(let error): + return error.localizedDescription + } + } +} diff --git a/Domain/Sources/Errors/OnDevice/OnDeviceRepositoryError.swift b/Domain/Sources/Errors/OnDevice/OnDeviceRepositoryError.swift new file mode 100644 index 00000000..07e385b6 --- /dev/null +++ b/Domain/Sources/Errors/OnDevice/OnDeviceRepositoryError.swift @@ -0,0 +1,53 @@ +import Foundation + +public enum OnDeviceRepositoryError: LocalizedError, Sendable { + /// Task 취소 + case cancelled + /// 네트워크 연결 실패 + case networkFailed + /// 모델 메모리 적재 실패 + case loadFailed + /// unknown + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: return "작업이 취소되었습니다" + case .networkFailed: return "네트워크 연결이 유실되었습니다" + case .loadFailed: return "모델을 메모리에 올리지 못했습니다" + case .unknown: return "다운로드에 실패했습니다" + } + } + + public static func mapDownloadError(_ error: Error) -> Self { + if error is CancellationError || + (error as? URLError)?.code == .cancelled || + (error as NSError).domain == NSURLErrorDomain && (error as NSError).code == NSURLErrorCancelled + { + return .cancelled + } + + if isNetworkError(error) { + return .networkFailed + } + + return .unknown(error) + } + + private static func isNetworkError(_ error: Error) -> Bool { + let nsError = error as NSError + + if nsError.domain == NSURLErrorDomain { + if nsError.code == NSURLErrorCancelled { + return false + } + return true + } + + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError { + return isNetworkError(underlying) + } + + return false + } +} diff --git a/Domain/Sources/Errors/OnDevice/OnDeviceStatusUseCaseError.swift b/Domain/Sources/Errors/OnDevice/OnDeviceStatusUseCaseError.swift new file mode 100644 index 00000000..977a57e3 --- /dev/null +++ b/Domain/Sources/Errors/OnDevice/OnDeviceStatusUseCaseError.swift @@ -0,0 +1,25 @@ +import Foundation + +public enum OnDeviceStatusUseCaseError: LocalizedError, Sendable { + /// Task 취소 + case cancelled + /// 네트워크 연결 실패 + case networkFailed + /// 모델 메모리 적재 실패 + case loadFailed + /// unknown + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .cancelled: + return "작업이 취소되었습니다" + case .networkFailed: + return "네트워크 연결이 유실되었습니다" + case .loadFailed: + return "모델을 메모리에 올리지 못했습니다" + case .unknown: + return "다운로드에 실패했습니다" + } + } +} diff --git a/Domain/Sources/Errors/VoiceNotes/Repositories/STTRepositoryError.swift b/Domain/Sources/Errors/VoiceNotes/Repositories/STTRepositoryError.swift new file mode 100644 index 00000000..82495b26 --- /dev/null +++ b/Domain/Sources/Errors/VoiceNotes/Repositories/STTRepositoryError.swift @@ -0,0 +1,26 @@ +import Foundation + +/// 음성 인식(STT) 리포지토리에서 발생할 수 있는 에러. +public enum STTRepositoryError: LocalizedError, Sendable { + /// 오디오 전사(Transcription) 실패. + case transcribeFailed + /// 모델 다운로드 실패. + case downloadFailed + /// 취소됨. + case cancelled + /// 알 수 없는 에러. + case unknown(any Error) + + public var errorDescription: String? { + switch self { + case .transcribeFailed: + return "음성 인식에 실패했습니다." + case .downloadFailed: + return "모델 다운로드에 실패했습니다." + case .cancelled: + return nil + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다: \(error.localizedDescription)" + } + } +} diff --git a/Domain/Sources/Errors/VoiceNotes/Repositories/SummaryRepositoryError.swift b/Domain/Sources/Errors/VoiceNotes/Repositories/SummaryRepositoryError.swift new file mode 100644 index 00000000..b2fe7f3d --- /dev/null +++ b/Domain/Sources/Errors/VoiceNotes/Repositories/SummaryRepositoryError.swift @@ -0,0 +1,22 @@ +import Foundation + +/// 요약(Summary) 리포지토리에서 발생할 수 있는 에러. +public enum SummaryRepositoryError: LocalizedError, Sendable { + /// 키워드·요약 생성 실패. + case summarizeFailed + /// 취소됨. + case cancelled + /// 알 수 없는 에러. + case unknown(any Error) + + public var errorDescription: String? { + switch self { + case .summarizeFailed: + return "요약 생성에 실패했습니다." + case .cancelled: + return nil + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다: \(error.localizedDescription)" + } + } +} diff --git a/Domain/Sources/Errors/VoiceNotes/Repositories/VoiceNoteRepositoryError.swift b/Domain/Sources/Errors/VoiceNotes/Repositories/VoiceNoteRepositoryError.swift new file mode 100644 index 00000000..1a5dc097 --- /dev/null +++ b/Domain/Sources/Errors/VoiceNotes/Repositories/VoiceNoteRepositoryError.swift @@ -0,0 +1,37 @@ +import Foundation + +/// 음성 메모 리포지토리 통합 에러. +public enum VoiceNoteRepositoryError: LocalizedError, Sendable { + case createFailed + case updateFailed + case fetchFailed(id: UUID?) + case fetchAllFailed(folderID: UUID?) + case fetchRecentFailed + case recordNotFound(id: UUID) + case defaultFolderNotFound + case cancelled + case unknown(Error) + + public var errorDescription: String? { + switch self { + case .createFailed: + return "음성 메모 생성에 실패했습니다." + case .updateFailed: + return "음성 메모 수정에 실패했습니다." + case .fetchFailed: + return "음성 메모 조회에 실패했습니다." + case .fetchAllFailed: + return "음성 메모 목록 조회에 실패했습니다." + case .fetchRecentFailed: + return "최근 기록 조회에 실패했습니다." + case .recordNotFound: + return "해당 음성 메모를 찾을 수 없습니다." + case .defaultFolderNotFound: + return "기본 폴더를 찾을 수 없습니다." + case .cancelled: + return nil + case .unknown(let error): + return error.localizedDescription + } + } +} diff --git a/Domain/Sources/Errors/VoiceNotes/UseCases/VoiceNoteUseCaseError.swift b/Domain/Sources/Errors/VoiceNotes/UseCases/VoiceNoteUseCaseError.swift new file mode 100644 index 00000000..f194f2a7 --- /dev/null +++ b/Domain/Sources/Errors/VoiceNotes/UseCases/VoiceNoteUseCaseError.swift @@ -0,0 +1,79 @@ +import Foundation + +/// 음성 메모 통합 유스케이스 에러. +public enum VoiceNoteUseCaseError: LocalizedError, Sendable { + // Common + case cancelled + case unknown(Error) + + // Create + case invalidDuration(duration: TimeInterval) + case emptyFileName + case unsupportedExtension(String) + case createFailed(VoiceNoteRepositoryError) + + // Update + case invalidTitle + case invalidLengthTitle + case updateFailed(VoiceNoteRepositoryError) + + // Fetch + case fetchFailed(VoiceNoteRepositoryError) + case recordNotFound(UUID) + + /// Audio Analysis (from AudioToSummary) + case analysisFailed(Error) + + public init(_ error: VoiceNoteRepositoryError) { + switch error { + case .cancelled: + self = .cancelled + case .recordNotFound(let id): + self = .recordNotFound(id) + case .createFailed: + self = .createFailed(error) + case .updateFailed: + self = .updateFailed(error) + case .fetchFailed, .fetchAllFailed, .fetchRecentFailed, .defaultFolderNotFound: + self = .fetchFailed(error) + case .unknown(let underlying): + self = .unknown(underlying) + } + } + + public init(_ error: FolderRepositoryError) { + switch error { + case .cancelled: + self = .cancelled + case .notFound, .duplicateName, .createFailed, .fetchFailed, .updateFailed: + self = .unknown(error) + case .unknown(let underlying): + self = .unknown(underlying) + } + } + + public var errorDescription: String? { + switch self { + case .cancelled: + return nil + case .invalidDuration(let duration): + return "유효하지 않은 녹음 시간입니다: \(duration)" + case .emptyFileName: + return "파일 이름이 비어 있습니다." + case .unsupportedExtension(let ext): + return "지원하지 않는 파일 형식입니다: \(ext)" + case .invalidTitle: + return "제목이 유효하지 않습니다." + case .invalidLengthTitle: + return "제목은 \(Policy.maxNameLength)자 이내여야 합니다." + case .createFailed(let error), .updateFailed(let error), .fetchFailed(let error): + return error.localizedDescription + case .recordNotFound: + return "해당 음성 메모를 찾을 수 없습니다." + case .analysisFailed(let error): + return "음성 분석에 실패했습니다: \(error.localizedDescription)" + case .unknown(let error): + return error.localizedDescription + } + } +} diff --git a/Domain/Sources/Errors/VoiceRecords/Repositories/VoiceRecordPlaybackRepositoryError.swift b/Domain/Sources/Errors/VoiceRecords/Repositories/VoiceRecordPlaybackRepositoryError.swift new file mode 100644 index 00000000..ab8cf22d --- /dev/null +++ b/Domain/Sources/Errors/VoiceRecords/Repositories/VoiceRecordPlaybackRepositoryError.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum VoiceRecordPlaybackRepositoryError: LocalizedError, Sendable { + case notPrepared + case prepareFailed + case playFailed + case pauseFailed + case seekFailed + case stopFailed + case unknown(any Error) + + public var errorDescription: String? { + switch self { + case .notPrepared: + return "재생할 오디오가 준비되지 않았습니다." + case .prepareFailed: + return "오디오 재생 준비에 실패했습니다." + case .playFailed: + return "오디오 재생을 시작할 수 없습니다." + case .pauseFailed: + return "오디오 재생을 일시정지할 수 없습니다." + case .seekFailed: + return "오디오 위치를 이동할 수 없습니다." + case .stopFailed: + return "오디오 재생을 중지할 수 없습니다." + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다: \(error.localizedDescription)" + } + } +} diff --git a/Domain/Sources/Errors/VoiceRecords/Repositories/VoiceRecordRepositoryError.swift b/Domain/Sources/Errors/VoiceRecords/Repositories/VoiceRecordRepositoryError.swift new file mode 100644 index 00000000..7c1e6348 --- /dev/null +++ b/Domain/Sources/Errors/VoiceRecords/Repositories/VoiceRecordRepositoryError.swift @@ -0,0 +1,50 @@ +import Foundation + +/// 녹음 및 마이크 권한 관련 리포지토리 에러 +public enum VoiceRecordRepositoryError: LocalizedError, Sendable { + /// 진행 중인 녹음이 없습니다 + case notRecording + /// 이미 녹음이 진행 중입니다 + case alreadyRecording + /// 일시 정지된 녹음이 없습니다 + case notPaused + /// 녹음 시작에 실패했습니다 + case startFailed + /// 녹음 일시 정지에 실패했습니다 + case pauseFailed + /// 녹음 재개에 실패했습니다 + case resumeFailed + /// 녹음 종료에 실패했습니다 + case finishFailed + /// 오디오 인코딩에 실패했습니다 + case encodingFailed + /// 사용자가 취소했습니다 + case cancelled + /// 알 수 없는 에러 + case unknown(any Error) + + public var errorDescription: String? { + switch self { + case .notRecording: + return "진행 중인 녹음이 없습니다." + case .alreadyRecording: + return "이미 녹음이 진행 중입니다." + case .notPaused: + return "일시 정지된 녹음이 없습니다." + case .startFailed: + return "녹음을 시작할 수 없습니다." + case .pauseFailed: + return "녹음 일시정지에 실패했습니다." + case .resumeFailed: + return "녹음 재시작에 실패했습니다." + case .finishFailed: + return "녹음 저장에 실패했습니다." + case .encodingFailed: + return "오디오 인코딩에 실패했습니다." + case .cancelled: + return nil + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다: \(error.localizedDescription)" + } + } +} diff --git a/Domain/Sources/Interfaces/CheckFirstLaunchRepository.swift b/Domain/Sources/Interfaces/CheckFirstLaunchRepository.swift new file mode 100644 index 00000000..52392dba --- /dev/null +++ b/Domain/Sources/Interfaces/CheckFirstLaunchRepository.swift @@ -0,0 +1,12 @@ +import Foundation + +/// 신규 사용자 여부를 판단하고 관리하는 리포지토리 프로토콜. +public protocol CheckFirstLaunchRepository: Sendable { + /// 사용자가 처음 앱을 실행했는지 확인만 합니다. (상태 변경 없음) + /// - Returns: 신규 사용자이면 true, 기존 사용자이면 false를 반환합니다. + func checkIsFirstLaunch() -> Bool + + /// 사용자가 처음 앱을 실행했는지 확인하고 필요한 상태 변경을 수행합니다. + /// - Returns: 신규 사용자이면 true, 기존 사용자이면 false를 반환합니다. + func checkAndMarkFirstLaunch() -> Bool +} diff --git a/Domain/Sources/Interfaces/Folders/FolderRepository.swift b/Domain/Sources/Interfaces/Folders/FolderRepository.swift new file mode 100644 index 00000000..c82d1d3e --- /dev/null +++ b/Domain/Sources/Interfaces/Folders/FolderRepository.swift @@ -0,0 +1,43 @@ +import Foundation + +/// 폴더(Folder) 엔티티의 CRU 를 담당하는 리포지토리 프로토콜. +@MainActor +public protocol FolderRepository: Sendable { + /// 새로운 폴더를 생성합니다. + /// - Parameter folder: 생성할 폴더 엔티티 + /// - Returns: 생성된 폴더 엔티티 + /// - Throws: `FolderRepositoryError.createFailed`, `.duplicateName` 등 + func create(_ folder: Folder) throws(FolderRepositoryError) -> Folder + + /// 모든 폴더 목록을 조회합니다. + /// - Returns: 조회된 폴더 목록 + /// - Throws: `FolderRepositoryError.fetchFailed` 등 + func fetchAll() throws(FolderRepositoryError) -> [Folder] + + /// ID로 특정 폴더를 조회합니다. + /// - Parameter id: 조회할 폴더의 UUID + /// - Returns: 조회된 폴더 엔티티 + /// - Throws: `FolderRepositoryError.notFound`, `.fetchFailed` 등 + func fetch(by id: UUID) throws(FolderRepositoryError) -> Folder + + /// 특정 종류의 폴더 목록을 조회합니다. (기본 폴더, 휴지통, 커스텀 등) + /// - Parameter kind: 조회할 폴더 종류 + /// - Returns: 해당 종류의 폴더 목록. 결과가 없으면 빈 배열 + /// - Throws: `FolderRepositoryError.fetchFailed` 등 + func fetch(by kind: FolderKind) throws(FolderRepositoryError) -> [Folder] + + /// 폴더 정보를 업데이트합니다. (이름 변경 등) + /// - Parameter folder: 업데이트할 폴더 엔티티 + /// - Returns: 업데이트된 폴더 엔티티 + /// - Throws: `FolderRepositoryError.updateFailed`, `.notFound`, `.duplicateName` 등 + func update(_ folder: Folder) throws(FolderRepositoryError) -> Folder + + /// 특정 종류의 폴더 목록을 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observe(by kind: FolderKind) throws(FolderRepositoryError) -> AsyncStream<[Folder]> + + /// 휴지통에 들어간(deletedAt != nil) 폴더 목록을 관찰합니다. + func observeTrashed() throws(FolderRepositoryError) -> AsyncStream<[Folder]> + + /// 폴더를 영구 삭제합니다. 안의 모든 노트도 cascade로 삭제됩니다. + func delete(id: UUID) throws(FolderRepositoryError) +} diff --git a/Domain/Sources/Interfaces/Languages/LanguageRepository.swift b/Domain/Sources/Interfaces/Languages/LanguageRepository.swift new file mode 100644 index 00000000..39d957e0 --- /dev/null +++ b/Domain/Sources/Interfaces/Languages/LanguageRepository.swift @@ -0,0 +1,12 @@ +import Foundation + +/// 언어 설정(Language)의 저장을 담당하는 리포지토리 프로토콜. +public protocol LanguageRepository: Sendable { + /// 현재 설정된 언어를 가져옵니다. + /// - Returns: 현재 설정된 언어 (기본값: ko) + func fetchLanguage() -> Language + + /// 새로운 언어를 저장합니다. + /// - Parameter language: 저장할 언어 (ko, en 등) + func saveLanguage(_ language: Language) +} diff --git a/Domain/Sources/Interfaces/MLXSupport/AvailableModelSupportRepository.swift b/Domain/Sources/Interfaces/MLXSupport/AvailableModelSupportRepository.swift new file mode 100644 index 00000000..d5460ee6 --- /dev/null +++ b/Domain/Sources/Interfaces/MLXSupport/AvailableModelSupportRepository.swift @@ -0,0 +1,12 @@ +import Foundation + +/// 온디바이스 AI 모델의 지원 여부 확인 및 생명주기(다운로드/로드)를 관리하는 리포지토리 인터페이스. +@MainActor +public protocol AvailableModelSupportRepository: Sendable { + /// 현재 디바이스의 하드웨어 사양(RAM 등) 및 유저 상태를 기반으로 지원 가능한 모델 정보를 확인합니다. ( MLX 모델 한정 ) + /// - Returns: 기기의 RAM 용량, 프로 유저 여부, 할당된 모델 타입을 포함하는 지원 정보 객체 + func checkMLXSupportModel() async -> ChaGokModelSupport + + /// 차곡에서 사용하는 모델을 전부 표기합니다. + func fetchSupportModels() async -> [ChaGokModelState] +} diff --git a/Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift b/Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift new file mode 100644 index 00000000..4aeeb306 --- /dev/null +++ b/Domain/Sources/Interfaces/OnDevice/OnDeviceRepository.swift @@ -0,0 +1,10 @@ +import Foundation + +public protocol OnDeviceRepository: Sendable { + /// 모델 파일을 다운로드하여 로컬 캐시에 저장합니다. (메모리 적재 X) + func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) + /// 모델을 제거합니다. + func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus + /// 현재 기기에 모델이 온전히 다운로드되어 존재하는지 실시간 디스크 상태를 체크합니다. + func checkStatus() async -> OnDeviceStatus +} diff --git a/Domain/Sources/Interfaces/VoiceNotes/STTRepository.swift b/Domain/Sources/Interfaces/VoiceNotes/STTRepository.swift new file mode 100644 index 00000000..e0771a3e --- /dev/null +++ b/Domain/Sources/Interfaces/VoiceNotes/STTRepository.swift @@ -0,0 +1,19 @@ +import Foundation + +/// 음성 인식(Speech-to-Text) 및 STT 권한을 담당하는 리포지토리 프로토콜. +public protocol STTRepository: Sendable { + /// 오디오 파일을 전사(Transcription)합니다. + /// - Parameter audioFilePath: 전사할 오디오 파일의 상대 경로 (예: `"VoiceRecords/file.m4a"`) + /// - Returns: 전사된 텍스트 엔티티 + /// - Throws: `STTRepositoryError` (전사 실패) + func transcribe(audioFilePath: String) async throws(STTRepositoryError) -> Transcript + + /// STT 권한이 허용되어 있는지 확인합니다. + /// - Returns: 현재 STT 권한 상태. + func checkSTTPermission() -> PermissionStatus + + /// STT 권한을 요청합니다. + /// - Returns: 요청 결과 권한 상태. + /// - Throws: `STTPermissionRepositoryError` + func requestSTTPermission() async throws(STTPermissionRepositoryError) -> PermissionStatus +} diff --git a/Domain/Sources/Interfaces/VoiceNotes/SummaryRepository.swift b/Domain/Sources/Interfaces/VoiceNotes/SummaryRepository.swift new file mode 100644 index 00000000..83f148c1 --- /dev/null +++ b/Domain/Sources/Interfaces/VoiceNotes/SummaryRepository.swift @@ -0,0 +1,13 @@ +import Foundation + +/// 요약(Summary) 및 분석을 담당하는 리포지토리 프로토콜. +public protocol SummaryRepository: Sendable { + /// 전사 텍스트를 분석하여 키워드와 요약을 생성합니다. + /// - Parameters: + /// - transcript: 분석할 전사 엔티티 + /// - language: 요약 및 키워드 생성에 사용할 출력 언어 + /// - Returns: 키워드 배열과 요약 엔티티의 튜플 + /// - Throws: `SummaryRepositoryError` (분석·요약 실패) + func summarize(transcript: Transcript, language: Language) async throws(SummaryRepositoryError) + -> (keywords: [Keyword], summary: Summary) +} diff --git a/Domain/Sources/Interfaces/VoiceNotes/VoiceNoteRepository.swift b/Domain/Sources/Interfaces/VoiceNotes/VoiceNoteRepository.swift new file mode 100644 index 00000000..9e5bfdb3 --- /dev/null +++ b/Domain/Sources/Interfaces/VoiceNotes/VoiceNoteRepository.swift @@ -0,0 +1,30 @@ +import Foundation + +/// 음성 메모 통합 리포지토리 프로토콜. +@MainActor +public protocol VoiceNoteRepository: Sendable { + /// 음성 메모를 영속화합니다. + func create(_ voiceNote: VoiceNote) throws(VoiceNoteRepositoryError) -> VoiceNote + + /// 음성 메모 정보를 업데이트합니다. + func update(_ voiceNote: VoiceNote) throws(VoiceNoteRepositoryError) -> VoiceNote + + /// 특정 음성 메모를 조회합니다. + func fetch(byId id: UUID) throws(VoiceNoteRepositoryError) -> VoiceNote + + /// ID로 음성 메모를 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observe(id: UUID) throws(VoiceNoteRepositoryError) -> AsyncStream + + /// 특정 폴더의 음성 메모 목록을 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observe(folderID: UUID) throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> + + /// 최근 생성된 음성 메모 목록을 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observeRecent(limit: Int) throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> + + /// 휴지통에 단독 삭제(folderID == trash.id AND deletedAt != nil)된 노트만 관찰합니다. + /// 폴더 cascade 삭제 노트는 부모 폴더가 휴지통에 있는 것으로 표현되므로 본 query에 잡히지 않습니다. + func observeTrashed() throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> + + /// 노트를 영구 삭제합니다. + func delete(id: UUID) throws(VoiceNoteRepositoryError) +} diff --git a/Domain/Sources/Interfaces/VoiceRecords/VoiceRecordPlaybackRepository.swift b/Domain/Sources/Interfaces/VoiceRecords/VoiceRecordPlaybackRepository.swift new file mode 100644 index 00000000..771c3480 --- /dev/null +++ b/Domain/Sources/Interfaces/VoiceRecords/VoiceRecordPlaybackRepository.swift @@ -0,0 +1,10 @@ +import Foundation + +@MainActor +public protocol VoiceRecordPlaybackRepository: Sendable { + func prepare(audioFilePath: String) throws(VoiceRecordPlaybackRepositoryError) -> AsyncStream + func play() throws(VoiceRecordPlaybackRepositoryError) + func pause() throws(VoiceRecordPlaybackRepositoryError) + func seek(to time: TimeInterval) throws(VoiceRecordPlaybackRepositoryError) + func stop() throws(VoiceRecordPlaybackRepositoryError) +} diff --git a/Domain/Sources/Interfaces/VoiceRecords/VoiceRecordRepository.swift b/Domain/Sources/Interfaces/VoiceRecords/VoiceRecordRepository.swift new file mode 100644 index 00000000..f7b8f250 --- /dev/null +++ b/Domain/Sources/Interfaces/VoiceRecords/VoiceRecordRepository.swift @@ -0,0 +1,36 @@ +import Foundation + +/// 예약, 설정, 오디오 녹음 및 관련 마이크 권한을 관리하는 리포지토리 프로토콜. +public protocol VoiceRecordRepository: Sendable { + /// 마이크 권한이 허용되어 있는지 확인합니다. + /// - Returns: 현재 마이크 권한 상태. + func checkMicrophonePermission() -> PermissionStatus + + /// 마이크 권한을 요청합니다. + /// - Returns: 요청 결과 권한 상태. + /// - Throws: `VoiceRecordRepositoryError.cancelled` 등 + func requestMicrophonePermission() async throws(VoiceRecordRepositoryError) -> PermissionStatus + + /// 녹음을 시작하고, 실시간 파형 데이터 스트림을 반환합니다. + /// - Returns: 녹음 중 생성되는 파형 스트림. + /// - Throws: `VoiceRecordRepositoryError.alreadyRecording`, `VoiceRecordRepositoryError.startFailed` + func startRecording() async throws(VoiceRecordRepositoryError) -> AsyncStream + + /// 진행 중인 녹음을 일시 정지합니다. + /// - Throws: `VoiceRecordRepositoryError.notRecording`, `VoiceRecordRepositoryError.pauseFailed` + func pauseRecording() async throws(VoiceRecordRepositoryError) + + /// 일시 정지된 녹음을 다시 이어서 녹음합니다. + /// - Throws: `VoiceRecordRepositoryError.notPaused`, `VoiceRecordRepositoryError.resumeFailed` + func resumeRecording() async throws(VoiceRecordRepositoryError) + + /// 녹음을 종료하고 저장한 뒤, 저장된 녹음 정보를 반환합니다. + /// - Returns: 저장된 녹음 엔티티 + /// - Throws: `VoiceRecordRepositoryError.notRecording`, `VoiceRecordRepositoryError.finishFailed`, + /// `VoiceRecordRepositoryError.encodingFailed` + func finishRecording() async throws(VoiceRecordRepositoryError) -> VoiceRecord + + /// 진행 중인 녹음을 취소하고 임시 파일을 삭제합니다. + /// - Throws: `VoiceRecordRepositoryError.cancelled` + func cancelRecording() async throws(VoiceRecordRepositoryError) +} diff --git a/Domain/Sources/Policy.swift b/Domain/Sources/Policy.swift new file mode 100644 index 00000000..53003cba --- /dev/null +++ b/Domain/Sources/Policy.swift @@ -0,0 +1,96 @@ +import Foundation + +public enum Policy { + /// 제목 최대 글자 수 + static let maxNameLength: Int = 50 + + /// AVAudioEngine installTap 버퍼 크기 (프레임 수) + /// 44100 Hz 기준 약 93ms/buffer → ~10.7 Waveform/sec + public static let waveformTapBufferSize: Int = 4096 + + /// Waveform 당 amplitude 샘플 수 (파형 막대 개수) + public static let waveformSamplesPerBuffer: Int = 20 + + /// 앱 언어 설정을 저장하기 위한 UserDefaults 키 + public static let appSelectedLanguageKey: String = "app_selected_language" + /// 기존 사용자 여부를 확인하기 위한 UserDefaults 키 + public static let isExistingUserKey: String = "isExistingUser" + + /// 온보딩 완료 시 자동 생성되는 기본 폴더 이름 + public static let defaultFolderName: String = "기본 폴더" + + /// 온보딩 완료 시 자동 생성되는 휴지통 폴더 이름 + public static let trashFolderName: String = "휴지통" + + /// 음성 노트 `Default Name` + public static let voiceNoteDefaultName: String = "새 기록" + + /// 녹음 PCM 버퍼 스트림의 최대 대기 개수 (초과 시 최신값 유지) + public static let audioBufferStreamBufferLimit: Int = 8 + + /// UI 파형 스트림의 최대 대기 개수 (초과 시 최신값 유지) + public static let waveformStreamBufferLimit: Int = 8 + + /// 최근 기록 탭에서 표시할 최대 VoiceNote 개수 + public static let recentVoiceNoteLimit: Int = 5 + + /// 재생 상태 스트림의 최대 대기 개수 (초과 시 최신값 유지) + public static let playbackStateStreamBufferLimit: Int = 8 + + /// 재생 진행률 업데이트 주기 (나노초, 0.1초) + public static let playbackProgressUpdateInterval: UInt64 = 100_000_000 + + /// 재생 빨리감기/뒤로가기 이동 간격 (초) + public static let playbackSkipInterval: TimeInterval = 5 + + /// 세그먼트 간 공백이 이 값(초)을 초과하면 새 섹션으로 분리 + public static let scriptGroupingPauseThreshold: TimeInterval = 2.0 +} + +// MARK: - 요약, 문법 교정 ( Prompt ) + +public extension Policy { + /// AI 요약 프롬프트 텍스트 입니다. + static func summaryPrompt(lang: String) -> String { + """ + You summarize transcript text. + Extract 3 to 5 concise keywords. + Write 1 to 3 concise key points in \(lang) that capture the main ideas. + Use fewer key points for short or single-topic transcripts, and more for longer or multi-topic ones. + Each key point should be a single standalone sentence without bullet markers or numbering. + Return content that matches the schema. + """ + } + + /// Keyword 요약 프롬프트 텍스트 입니다 + static func keywordPrompt(transcript: String) -> String { + """ + Read the following transcript and generate keywords and key points. + + Transcript: + \(transcript) + """ + } + + /// STT를 통해 전사된 문장을 교정하는 프롬포트 입니다. + static let sttCorrectionPrompt: String = """ + You are a grammar correction assistant. + + Correct grammar, spelling, and punctuation only. + Preserve meaning and tone. + Keep the original language of the input text. + Do not translate or rewrite unnecessarily. + Return only the corrected text. + """ + + /// 교정할 문장을 주입하는 사용자 프롬프트 텍스트 입니다. + static func correctionPrompt(text: String) -> String { + """ + Correct the grammar of the following text and polish it to sound natural. + Do not include any explanations, introduction, or additional text. Return ONLY the corrected text. + + Text: + \(text) + """ + } +} diff --git a/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift new file mode 100644 index 00000000..20b36a54 --- /dev/null +++ b/Domain/Sources/Services/VoiceNotes/VoiceNoteAnalysisService.swift @@ -0,0 +1,224 @@ +import Core +import Foundation + +/// 음성 메모의 전사 → 요약 파이프라인 실행을 오케스트레이션하는 도메인 서비스. +/// +/// 진행 중 Task 핸들과 이전 상태(previousState)를 메모리에 보관하며, +/// 취소/앱 종료 시 DB 상태를 이전 상태로 되돌린다. +/// 뷰 생명주기와 독립적으로 동작한다. +/// +/// - TODO: 크래시 등으로 `applicationWillTerminate`가 호출되지 못한 경우 +/// DB에 `.transcribing` / `.summarizing` / `.regenerating` 상태가 잔존할 수 있다. +/// 앱 재기동 시 stuck 상태를 스캔해 합리적 상태로 revert 하는 정책 필요. +@MainActor +public protocol VoiceNoteAnalysisService: Sendable { + /// 현재 `analysisState`에 따라 전사 또는 요약을 큐잉한다. + /// 이미 진행 중인 노트면 no-op. + func enqueue(voiceNoteID: UUID) + + /// 완료/실패 상태의 요약을 재생성한다. + func regenerate(voiceNoteID: UUID) + + /// 특정 노트의 분석을 취소하고 전이 상태라면 이전 상태로 되돌린다. + func cancel(voiceNoteID: UUID) + + /// 진행 중인 모든 분석을 취소하고 전이 상태라면 이전 상태로 되돌린다. + func cancelAll() +} + +public final class DefaultVoiceNoteAnalysisService: VoiceNoteAnalysisService { + private struct Entry { + let task: Task + let previousState: AnalysisState + } + + 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 + + public init( + voiceNoteRepository: any VoiceNoteRepository, + sttRepository: any STTRepository, + summaryRepository: any SummaryRepository, + languageRepository: any LanguageRepository + ) { + self.voiceNoteRepository = voiceNoteRepository + self.sttRepository = sttRepository + self.summaryRepository = summaryRepository + self.languageRepository = languageRepository + } + + // MARK: - Public API + + public func enqueue(voiceNoteID: UUID) { + 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 + } + } + + public func regenerate(voiceNoteID: UUID) { + 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: + break + } + } + + public func cancel(voiceNoteID: UUID) { + guard let entry = entries.removeValue(forKey: voiceNoteID) else { return } + entry.task.cancel() + revertState(voiceNoteID: voiceNoteID, to: entry.previousState) + } + + public func cancelAll() { + let snapshot = entries + entries.removeAll() + for (id, entry) in snapshot { + entry.task.cancel() + revertState(voiceNoteID: id, to: entry.previousState) + } + } + + // MARK: - Pipeline + + private func startTranscription(for voiceNote: VoiceNote, previousState: AnalysisState) { + persist(voiceNote: voiceNote, analysisState: .transcribing) + let task = Task { [weak self] in + guard let self else { return } + do { + let transcript = try await sttRepository.transcribe( + audioFilePath: voiceNote.voiceRecord.audioFilePath + ) + if Task.isCancelled { return } + let withTranscript = makeUpdated( + from: voiceNote, + transcript: transcript, + analysisState: .transcribed + ) + persist(voiceNote: withTranscript) + if Task.isCancelled { return } + entries.removeValue(forKey: voiceNote.id) + startSummarization(for: withTranscript, previousState: .transcribed) + } catch { + AppLogger.error(error) + if !Task.isCancelled { + persist(voiceNote: voiceNote, analysisState: .transcriptionFailed) + } + entries.removeValue(forKey: voiceNote.id) + } + } + entries[voiceNote.id] = Entry(task: task, previousState: previousState) + } + + private func startSummarization( + for voiceNote: VoiceNote, + previousState: AnalysisState, + transientState: AnalysisState = .summarizing + ) { + guard let transcript = voiceNote.transcript else { return } + persist(voiceNote: voiceNote, analysisState: transientState) + let task = Task { [weak self] in + guard let self else { return } + do { + let language = languageRepository.fetchLanguage() + let (keywords, summary) = try await summaryRepository.summarize( + transcript: transcript, + language: language + ) + if Task.isCancelled { return } + let completed = makeUpdated( + from: voiceNote, + keywords: keywords, + summary: summary, + analysisState: .completed + ) + persist(voiceNote: completed) + } catch { + AppLogger.error(error) + if !Task.isCancelled { + persist(voiceNote: voiceNote, analysisState: .summarizationFailed) + } + } + entries.removeValue(forKey: voiceNote.id) + } + entries[voiceNote.id] = Entry(task: task, previousState: previousState) + } + + // MARK: - Storage Helpers + + private func fetch(_ id: UUID) -> VoiceNote? { + do { + return try voiceNoteRepository.fetch(byId: id) + } catch { + AppLogger.error(error) + return nil + } + } + + private func persist(voiceNote: VoiceNote) { + do { + _ = try voiceNoteRepository.update(voiceNote) + } catch { + AppLogger.error(error) + } + } + + private func persist(voiceNote: VoiceNote, analysisState: AnalysisState) { + let updated = makeUpdated(from: voiceNote, analysisState: analysisState) + 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: + persist(voiceNote: current, analysisState: previousState) + default: + break + } + } + + private func makeUpdated( + from voiceNote: VoiceNote, + transcript: Transcript? = nil, + keywords: [Keyword]? = nil, + summary: Summary? = nil, + analysisState: AnalysisState + ) -> VoiceNote { + VoiceNote( + id: voiceNote.id, + title: voiceNote.title, + createdAt: voiceNote.createdAt, + updatedAt: voiceNote.updatedAt, + folderID: voiceNote.folderID, + voiceRecord: voiceNote.voiceRecord, + keywords: keywords ?? voiceNote.keywords, + transcript: transcript ?? voiceNote.transcript, + summary: summary ?? voiceNote.summary, + deletedAt: voiceNote.deletedAt, + analysisState: analysisState + ) + } +} diff --git a/Domain/Sources/UseCases/Folders/FolderUseCase.swift b/Domain/Sources/UseCases/Folders/FolderUseCase.swift new file mode 100644 index 00000000..9cd26c7a --- /dev/null +++ b/Domain/Sources/UseCases/Folders/FolderUseCase.swift @@ -0,0 +1,246 @@ +import Core +import Foundation + +/// 폴더 생성·조회·수정을 담당하는 유스케이스 프로토콜. +@MainActor +public protocol FolderUseCase: Sendable { + /// 새로운 폴더를 생성합니다. + /// - Parameter name: 생성할 폴더의 이름 + /// - Returns: 생성된 `Folder` 엔티티 + func create(name: String) throws(FolderUseCaseError) -> Folder + + /// 앱 최초 실행 시 시스템이 사용하는 기본 폴더를 생성합니다. + /// - Returns: 생성된 `Folder` 엔티티 + func createDefault() throws(FolderUseCaseError) -> Folder + + /// 앱 최초 실행 시 시스템이 사용하는 휴지통 폴더를 생성합니다. + /// - Returns: 생성된 `Folder` 엔티티 + func createTrash() throws(FolderUseCaseError) -> Folder + + /// 삭제되지 않은 모든 폴더 목록을 조회합니다. + /// - Returns: 조회된 `Folder` 배열 + func fetchAll() throws(FolderUseCaseError) -> [Folder] + + /// 기본 폴더(kind == .default)를 조회합니다. + func fetchDefault() throws(FolderUseCaseError) -> Folder + + /// 휴지통 폴더(kind == .trash)를 조회합니다. + func fetchTrash() throws(FolderUseCaseError) -> Folder + + /// 개인 폴더(kind == .custom) 목록을 조회합니다. + /// - Returns: 삭제 가능한 `Folder` 배열 + func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] + + /// ID로 특정 폴더를 조회합니다. + /// - Parameter id: 조회할 폴더의 UUID + /// - Returns: 조회된 `Folder` 엔티티 + func fetch(by id: UUID) throws(FolderUseCaseError) -> Folder + + /// 폴더 정보를 업데이트합니다. + /// - Parameter folder: 업데이트할 `Folder` 엔티티 + /// - Returns: 업데이트된 `Folder` 엔티티 + func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder + + /// 개인 폴더 목록을 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> + + /// 휴지통에 있는(삭제된) 폴더 목록을 관찰합니다. + func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> + + /// 폴더를 휴지통으로 이동합니다. 안의 노트는 부모 폴더가 휴지통에 있는 형태로 cascade 표현됩니다. + /// - Parameter folderID: 이동할 폴더의 UUID + func moveToTrash(folderID: UUID) throws(FolderUseCaseError) + + /// 휴지통에 있는 폴더를 복원합니다. cascade로 함께 이동됐던 노트도 자연스럽게 복원됩니다. + /// - Parameter folderID: 복원할 폴더의 UUID + func restore(folderID: UUID) throws(FolderUseCaseError) + + /// 폴더를 영구 삭제합니다. 안의 모든 노트도 cascade로 삭제됩니다. + /// - Parameter folderID: 삭제할 폴더의 UUID + func delete(folderID: UUID) throws(FolderUseCaseError) +} + +public struct DefaultFolderUseCase: FolderUseCase { + private let repository: any FolderRepository + + public init(repository: any FolderRepository) { + self.repository = repository + } + + public func create(name: String) throws(FolderUseCaseError) -> Folder { + let trimName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimName.isEmpty, trimName == name else { throw .invalidName } + guard trimName.count <= Policy.maxNameLength else { throw .invalidLengthName } + guard trimName != Policy.defaultFolderName else { throw .reservedName } + + let folder = Folder(name: trimName, kind: .custom) + do { + return try repository.create(folder) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func createDefault() throws(FolderUseCaseError) -> Folder { + let folder = Folder(name: Policy.defaultFolderName, kind: .default) + do { + return try repository.create(folder) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func createTrash() throws(FolderUseCaseError) -> Folder { + let folder = Folder(name: Policy.trashFolderName, kind: .trash) + do { + return try repository.create(folder) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func fetchDefault() throws(FolderUseCaseError) -> Folder { + let folders: [Folder] + do { + folders = try repository.fetch(by: .default) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + guard let folder = folders.first else { throw .notFound } + return folder + } + + public func fetchTrash() throws(FolderUseCaseError) -> Folder { + let folders: [Folder] + do { + folders = try repository.fetch(by: .trash) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + guard let folder = folders.first else { throw .notFound } + return folder + } + + public func fetchAll() throws(FolderUseCaseError) -> [Folder] { + do { + let folders = try repository.fetchAll() + return folders.filter { $0.deletedAt == nil } + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] { + do { + let folders = try repository.fetchAll() + return folders.filter { $0.deletedAt == nil && $0.kind == .custom } + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func fetch(by id: UUID) throws(FolderUseCaseError) -> Folder { + do { + return try repository.fetch(by: id) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + // Repository observe(by: .custom)의 predicate가 parentID == nil 조건을 포함하므로 + // 휴지통 이동된 폴더(parentID = trash.id)는 emit에서 자동 제외됨 → 추가 filter 불필요 + do { + return try repository.observe(by: .custom) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + do { + return try repository.observeTrashed() + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder { + let trimName = folder.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimName.isEmpty, trimName == folder.name else { throw .invalidName } + guard trimName.count <= Policy.maxNameLength else { throw .invalidLengthName } + guard trimName != Policy.defaultFolderName else { throw .reservedName } + + var updateFolder = folder + updateFolder.name = trimName + do { + return try repository.update(updateFolder) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func moveToTrash(folderID: UUID) throws(FolderUseCaseError) { + let trash = try fetchTrash() + + let folder: Folder + do { + folder = try repository.fetch(by: folderID) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + + var trashed = folder + trashed.parentID = trash.id + trashed.deletedAt = .now + + do { + _ = try repository.update(trashed) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func restore(folderID: UUID) throws(FolderUseCaseError) { + let folder: Folder + do { + folder = try repository.fetch(by: folderID) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + + var restored = folder + restored.parentID = nil + restored.deletedAt = nil + + do { + _ = try repository.update(restored) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } + + public func delete(folderID: UUID) throws(FolderUseCaseError) { + do { + try repository.delete(id: folderID) + } catch { + AppLogger.error(error) + throw FolderUseCaseError(error) + } + } +} diff --git a/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift b/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift new file mode 100644 index 00000000..51942f71 --- /dev/null +++ b/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift @@ -0,0 +1,206 @@ +import Core +import Foundation + +public protocol OnDeviceStatusUseCase: Sendable { + /// 구독 함수 + func subscribe(model: ChaGokModel) async -> AsyncStream + /// 다운로드 + func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) + /// 모델 제거 + func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) + /// 현재 상태 조회 + func checkStatus(model: ChaGokModel) async -> OnDeviceStatus +} + +public actor DefaultOnDeviceStatusUseCase: OnDeviceStatusUseCase { + private let whisperRepository: any OnDeviceRepository + private let mlxRepository: any OnDeviceRepository + + private var isDownloading: [ChaGokModel: Bool] = [:] + private var latest: [ChaGokModel: OnDeviceStatus] = [:] + private var subscribers: [UUID: (model: ChaGokModel, cont: AsyncStream.Continuation)] = [:] + + /// 모델별 현재 활성 다운로드 Task 및 식별자 + private var downloadTasks: [ChaGokModel: Task] = [:] + private var downloadIDs: [ChaGokModel: UUID] = [:] + + public init( + whisperRepository: any OnDeviceRepository, + mlxRepository: any OnDeviceRepository + ) { + self.whisperRepository = whisperRepository + self.mlxRepository = mlxRepository + } + + public func subscribe(model: ChaGokModel) -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { cont in + let id = UUID() + Task { + await self.syncStatus(model: model) + await self.addSubscriber(id: id, model: model, continuation: cont) + } + + cont.onTermination = { _ in + Task { await self.unsubscribe(id: id) } + } + } + } + + public func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) { + guard let repo = repo(for: model) else { return } + + // 기존 다운로드가 있으면 취소 + downloadTasks[model]?.cancel() + downloadTasks[model] = nil + + let downloadID = UUID() + downloadIDs[model] = downloadID + isDownloading[model] = true + + await publish(model: model, status: OnDeviceStatus(storage: .downloading(progress: 0))) + + // 취소 가능한 내부 Task로 감싸서 관리 + let task = Task { + try await repo.download { progress in + Task { [model, downloadID] in + // 이 다운로드가 아직 활성 상태인 경우에만 progress 발행 + guard await self.downloadIDs[model] == downloadID else { return } + await self.publish( + model: model, + status: OnDeviceStatus(storage: .downloading(progress: progress)) + ) + } + } + } + downloadTasks[model] = task + + do { + try await task.value + + // 이 다운로드가 아직 활성 상태인 경우에만 완료 처리 + guard downloadIDs[model] == downloadID else { return } + downloadTasks[model] = nil + isDownloading[model] = false + await publish(model: model, status: OnDeviceStatus(storage: .downloaded)) + } catch { + // 이 다운로드가 이미 교체된 경우(새 다운로드가 시작됨) 조용히 종료 + guard downloadIDs[model] == downloadID else { + throw .cancelled + } + downloadTasks[model] = nil + isDownloading[model] = false + + let mappedError: OnDeviceStatusUseCaseError = if error is CancellationError { + .cancelled + } else if let repoError = error as? OnDeviceRepositoryError { + switch repoError { + case .cancelled: + .cancelled + case .networkFailed: + .networkFailed + case .loadFailed: + .loadFailed + case .unknown(let underlying): + .unknown(underlying) + } + } else { + .unknown(error) + } + + AppLogger.error(mappedError) + if case .cancelled = mappedError { + // 사용자 취소 시 상태를 .notDownloaded로 복구하여 구독 모델들에 알림 + await publish(model: model, status: OnDeviceStatus(storage: .notDownloaded)) + } else { + await publish(model: model, status: OnDeviceStatus(storage: .failed)) + } + throw mappedError + } + } + + public func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) { + // 진행 중인 다운로드 취소 + downloadTasks[model]?.cancel() + downloadTasks[model] = nil + downloadIDs[model] = nil + isDownloading[model] = false + + guard let repo = repo(for: model) else { return } + do { + let status = try await repo.delete() + await publish(model: model, status: status) + } catch { + AppLogger.error(error) + throw error + } + } + + public func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { + if isDownloading[model] == true { + return latest[model] ?? OnDeviceStatus(storage: .downloading(progress: 0.0)) + } + if let repo = repo(for: model) { + let status = await repo.checkStatus() + latest[model] = status + return status + } + return OnDeviceStatus(storage: .notDownloaded) + } + + private func syncStatus(model: ChaGokModel) async { + _ = await checkStatus(model: model) + } + + private var lastPublishedTime: [ChaGokModel: Double] = [:] + + private func publish(model: ChaGokModel, status: OnDeviceStatus) async { + // 취소/삭제 후 남아있는 progress 콜백이 .downloading을 다시 발행하는 것을 방지 + if case .downloading = status.storage, isDownloading[model] != true { + return + } + + if case .downloading(let progress) = status.storage { + let currentTime = Date().timeIntervalSince1970 + let lastTime = lastPublishedTime[model] ?? 0.0 + if currentTime - lastTime < 0.05, progress < 1.0, progress > 0.0 { + return + } + lastPublishedTime[model] = currentTime + } else { + lastPublishedTime[model] = nil + } + + latest[model] = status + for (_, item) in subscribers where item.model == model { + item.cont.yield(status) + AppLogger.info("📢 [UseCase] 상태 발행: \(status)") + } + } + + private func addSubscriber( + id: UUID, + model: ChaGokModel, + continuation: AsyncStream.Continuation + ) async { + subscribers[id] = (model, continuation) + if let status = latest[model] { + continuation.yield(status) + } + } + + private func unsubscribe(id: UUID) { + subscribers[id]?.cont.finish() + subscribers[id] = nil + } + + private func repo(for model: ChaGokModel) -> (any OnDeviceRepository)? { + switch model { + case .whisper: + whisperRepository + case .gemma4_e2b_4bit: + mlxRepository + case .none: + nil + } + } +} diff --git a/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift b/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift new file mode 100644 index 00000000..32c6f295 --- /dev/null +++ b/Domain/Sources/UseCases/VoiceNotes/VoiceNoteUseCase.swift @@ -0,0 +1,313 @@ +import Core +import Foundation + +/// 음성 메모 통합 유스케이스 프로토콜. +@MainActor +public protocol VoiceNoteUseCase: Sendable { + /// 새로운 음성 메모를 생성하고 분석 파이프라인을 시작합니다. + func create(_ voiceRecord: VoiceRecord) throws(VoiceNoteUseCaseError) -> VoiceNote + + /// 특정 음성 메모를 조회합니다. + func fetch(byId id: UUID) throws(VoiceNoteUseCaseError) -> VoiceNote + + /// 음성 메모 정보를 업데이트합니다. + func update(_ voiceNote: VoiceNote) throws(VoiceNoteUseCaseError) -> VoiceNote + + /// ID로 음성 메모를 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observe(id: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream + + /// 특정 폴더의 음성 메모 목록을 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observe(folderID: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> + + /// 최근 생성된 음성 메모 목록을 관찰합니다. 첫 emit은 현재 상태이며, 이후 변경 시 재emit됩니다. + func observeRecent(limit: Int) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> + + /// 휴지통에 단독 이동된 노트 목록을 관찰합니다. + func observeTrashed() throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> + + /// 완료/실패 상태의 요약을 재생성합니다. + func regenerateSummary(id: UUID) + + /// 노트를 휴지통으로 단독 이동합니다. 원본 폴더 정보는 `originalFolderID`에 보존됩니다. + /// - Parameter noteID: 이동할 노트의 UUID + func moveToTrash(noteID: UUID) throws(VoiceNoteUseCaseError) + + /// 휴지통에 있는 노트를 복원합니다. + /// 원본 폴더가 살아있으면 원본으로, 아니면 기본 폴더로 복원합니다. + /// - Parameter noteID: 복원할 노트의 UUID + func restore(noteID: UUID) throws(VoiceNoteUseCaseError) + + /// 노트를 영구 삭제합니다. + /// - Parameter noteID: 삭제할 노트의 UUID + func delete(noteID: UUID) throws(VoiceNoteUseCaseError) +} + +/// 음성 메모 통합 유스케이스 구현체. +public struct DefaultVoiceNoteUseCase: VoiceNoteUseCase { + private let repository: VoiceNoteRepository + private let folderRepository: FolderRepository + private let analysisService: any VoiceNoteAnalysisService + + public init( + repository: VoiceNoteRepository, + folderRepository: FolderRepository, + analysisService: any VoiceNoteAnalysisService + ) { + self.repository = repository + self.folderRepository = folderRepository + self.analysisService = analysisService + } + + // MARK: - Create + + public func create(_ voiceRecord: VoiceRecord) throws(VoiceNoteUseCaseError) -> VoiceNote { + // 1. 녹음 시간 검증 + if !voiceRecord.duration.isFinite || voiceRecord.duration <= 0 { + let error = VoiceNoteUseCaseError.invalidDuration(duration: voiceRecord.duration) + AppLogger.error(error) + throw error + } + + // 2. 파일 이름 및 확장자 검증 + let fileName = (voiceRecord.audioFilePath as NSString).lastPathComponent + if fileName.isEmpty { + let error = VoiceNoteUseCaseError.emptyFileName + AppLogger.error(error) + throw error + } + + let pathExtension = (voiceRecord.audioFilePath as NSString).pathExtension + if AudioFileFormat(extension: pathExtension) == nil { + let error = VoiceNoteUseCaseError.unsupportedExtension(pathExtension) + AppLogger.error(error) + throw error + } + + // 3. 기본 폴더 결정 (어느 폴더에 저장할지는 비즈니스 결정) + let defaultFolders: [Folder] + do { + defaultFolders = try folderRepository.fetch(by: .default) + } catch { + AppLogger.error(error) + throw .unknown(error) + } + guard let defaultFolder = defaultFolders.first else { + throw .unknown(FolderRepositoryError.notFound) + } + + // 4. VoiceNote 모델 구성 (제목 등 비즈니스 규칙은 UseCase에서 결정) + let voiceNote = VoiceNote( + title: Policy.voiceNoteDefaultName, + createdAt: voiceRecord.createdAt, + updatedAt: voiceRecord.createdAt, + folderID: defaultFolder.id, + voiceRecord: voiceRecord, + analysisState: .pending + ) + + // 5. 영속화 + let created: VoiceNote + do { + created = try repository.create(voiceNote) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + + // 6. 분석 파이프라인 자동 시작 (fire-and-forget) + analysisService.enqueue(voiceNoteID: created.id) + return created + } + + // MARK: - Fetch + + public func fetch(byId id: UUID) throws(VoiceNoteUseCaseError) -> VoiceNote { + do { + return try repository.fetch(byId: id) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + // MARK: - Update + + public func update(_ voiceNote: VoiceNote) throws(VoiceNoteUseCaseError) -> VoiceNote { + // 1. 제목 유효성 검사 (공백) + let trimmedTitle = voiceNote.title.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedTitle.isEmpty || voiceNote.title != trimmedTitle { + throw .invalidTitle + } + + // 2. 제목 길이 검사 + if trimmedTitle.count > Policy.maxNameLength { + throw .invalidLengthTitle + } + + // 3. 수정 시각 갱신 + var updatedNote = voiceNote + updatedNote.title = trimmedTitle + updatedNote.updatedAt = .now + + do { + return try repository.update(updatedNote) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + // MARK: - Observe + + public func observe(id: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream { + do { + return try repository.observe(id: id) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + public func observe(folderID: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + do { + return try repository.observe(folderID: folderID) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + public func observeRecent(limit: Int) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + do { + return try repository.observeRecent(limit: limit) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + public func observeTrashed() throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + do { + return try repository.observeTrashed() + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + // MARK: - Analysis Facade + + public func regenerateSummary(id: UUID) { + analysisService.regenerate(voiceNoteID: id) + } + + // MARK: - Trash + + public func moveToTrash(noteID: UUID) throws(VoiceNoteUseCaseError) { + // 1. 휴지통 폴더 resolve + let trashFolders: [Folder] + do { + trashFolders = try folderRepository.fetch(by: .trash) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + guard let trashFolder = trashFolders.first else { + throw .unknown(FolderRepositoryError.notFound) + } + + // 2. 노트 fetch + let note: VoiceNote + do { + note = try repository.fetch(byId: noteID) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + + // 3. 상태 전이: 원본 폴더 스냅샷 + 휴지통으로 이동 + var trashed = note + trashed.originalFolderID = note.folderID + trashed.folderID = trashFolder.id + trashed.deletedAt = .now + + do { + _ = try repository.update(trashed) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + public func restore(noteID: UUID) throws(VoiceNoteUseCaseError) { + // 1. 노트 fetch + let note: VoiceNote + do { + note = try repository.fetch(byId: noteID) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + + // 2. 복원 대상 폴더 결정: 원본이 살아있으면 원본, 아니면 기본 폴더 + let targetFolderID = try resolveRestoreTargetFolderID(for: note) + + // 3. 상태 전이: 폴더 복귀 + 삭제 흔적 초기화 + var restored = note + restored.folderID = targetFolderID + restored.deletedAt = nil + restored.originalFolderID = nil + + do { + _ = try repository.update(restored) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + public func delete(noteID: UUID) throws(VoiceNoteUseCaseError) { + do { + try repository.delete(id: noteID) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + + private func resolveRestoreTargetFolderID( + for note: VoiceNote + ) throws(VoiceNoteUseCaseError) -> UUID { + // 원본 폴더가 지정돼 있고 휴지통에 들어가지 않았다면 원본으로 복원 + if let originalID = note.originalFolderID { + do { + let original = try folderRepository.fetch(by: originalID) + if original.deletedAt == nil { + return original.id + } + } catch { + // 원본 폴더가 영구 삭제된 정상 경로(notFound)는 fallback 진행, + // 그 외 시스템 에러(DB 연결 실패 등)는 묻지 않고 그대로 전파 + if case .notFound = error { + // fallback으로 진행 + } else { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + } + } + + // fallback: 기본 폴더 + let defaultFolders: [Folder] + do { + defaultFolders = try folderRepository.fetch(by: .default) + } catch { + AppLogger.error(error) + throw VoiceNoteUseCaseError(error) + } + guard let defaultFolder = defaultFolders.first else { + throw .unknown(FolderRepositoryError.notFound) + } + return defaultFolder.id + } +} diff --git a/Domain/Testing/Entities/Stubs/AudioPlaybackState+Stub.swift b/Domain/Testing/Entities/Stubs/AudioPlaybackState+Stub.swift new file mode 100644 index 00000000..90825281 --- /dev/null +++ b/Domain/Testing/Entities/Stubs/AudioPlaybackState+Stub.swift @@ -0,0 +1,16 @@ +@testable import Domain +import Foundation + +public extension AudioPlaybackState { + static func stub( + status: AudioPlaybackState.Status = .idle, + currentTime: TimeInterval = 0, + duration: TimeInterval = 60 + ) -> AudioPlaybackState { + AudioPlaybackState( + status: status, + currentTime: currentTime, + duration: duration + ) + } +} diff --git a/Domain/Testing/Entities/Stubs/Folder+Stub.swift b/Domain/Testing/Entities/Stubs/Folder+Stub.swift new file mode 100644 index 00000000..8018d6ef --- /dev/null +++ b/Domain/Testing/Entities/Stubs/Folder+Stub.swift @@ -0,0 +1,22 @@ +@testable import Domain +import Foundation + +public extension Folder { + static func stub( + id: UUID = UUID(), + name: String = "Stub Folder", + createdAt: Date = Date(), + voiceNoteIDs: [UUID] = [], + kind: FolderKind = .custom, + deletedAt: Date? = nil + ) -> Folder { + Folder( + id: id, + name: name, + createdAt: createdAt, + voiceNoteIDs: voiceNoteIDs, + kind: kind, + deletedAt: deletedAt + ) + } +} diff --git a/Domain/Testing/Entities/Stubs/Keyword+Stub.swift b/Domain/Testing/Entities/Stubs/Keyword+Stub.swift new file mode 100644 index 00000000..42efa79c --- /dev/null +++ b/Domain/Testing/Entities/Stubs/Keyword+Stub.swift @@ -0,0 +1,12 @@ +@testable import Domain +import Foundation + +public extension Keyword { + static func stub( + id: UUID = UUID(), + noteID: UUID = UUID(), + word: String = "mock keyword" + ) -> Keyword { + Keyword(id: id, noteID: noteID, word: word) + } +} diff --git a/Domain/Testing/Entities/Stubs/Summary+Stub.swift b/Domain/Testing/Entities/Stubs/Summary+Stub.swift new file mode 100644 index 00000000..79af5717 --- /dev/null +++ b/Domain/Testing/Entities/Stubs/Summary+Stub.swift @@ -0,0 +1,12 @@ +@testable import Domain +import Foundation + +public extension Summary { + static func stub( + id: UUID = UUID(), + createdAt: Date = Date(), + text: String = "mock summary" + ) -> Summary { + Summary(id: id, createdAt: createdAt, text: text) + } +} diff --git a/Domain/Testing/Entities/Stubs/Transcript+Stub.swift b/Domain/Testing/Entities/Stubs/Transcript+Stub.swift new file mode 100644 index 00000000..b7badda5 --- /dev/null +++ b/Domain/Testing/Entities/Stubs/Transcript+Stub.swift @@ -0,0 +1,17 @@ +@testable import Domain +import Foundation + +public extension Transcript { + static func stub( + id: UUID = UUID(), + createdAt: Date = Date(), + updatedAt: Date? = nil, + sections: [TranscriptSection] = [TranscriptSection(timestamp: 0, text: "mock transcript")] + ) -> Transcript { + Transcript(id: id, createdAt: createdAt, updatedAt: updatedAt, sections: sections) + } + + static func stub(text: String) -> Transcript { + Transcript(sections: [TranscriptSection(timestamp: 0, text: text)]) + } +} diff --git a/Domain/Testing/Entities/Stubs/VoiceNote+Stub.swift b/Domain/Testing/Entities/Stubs/VoiceNote+Stub.swift new file mode 100644 index 00000000..9a1ab231 --- /dev/null +++ b/Domain/Testing/Entities/Stubs/VoiceNote+Stub.swift @@ -0,0 +1,41 @@ +@testable import Domain +import Foundation + +public extension VoiceNote { + static func stub( + id: UUID = UUID(), + title: String = "Test Voice Note", + createdAt: Date = Date(), + updatedAt: Date = Date(), + folderID: UUID = UUID(), + voiceRecord: VoiceRecord = .stub(), + keywords: [Keyword] = [], + transcript: Transcript? = nil, + summary: Summary? = nil, + deletedAt: Date? = nil, + analysisState: AnalysisState? = nil + ) -> VoiceNote { + let resolvedState: AnalysisState = if let analysisState { + analysisState + } else if summary != nil, transcript != nil { + .completed + } else if transcript != nil { + .transcribed + } else { + .pending + } + return VoiceNote( + id: id, + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + folderID: folderID, + voiceRecord: voiceRecord, + keywords: keywords, + transcript: transcript, + summary: summary, + deletedAt: deletedAt, + analysisState: resolvedState + ) + } +} diff --git a/Domain/Testing/Entities/Stubs/VoiceRecord+Stub.swift b/Domain/Testing/Entities/Stubs/VoiceRecord+Stub.swift new file mode 100644 index 00000000..6b752c5a --- /dev/null +++ b/Domain/Testing/Entities/Stubs/VoiceRecord+Stub.swift @@ -0,0 +1,18 @@ +@testable import Domain +import Foundation + +public extension VoiceRecord { + static func stub( + id: UUID = UUID(), + createdAt: Date = Date(), + audioFilePath: String = "VoiceRecords/test.m4a", + duration: Double = 60.0 + ) -> VoiceRecord { + VoiceRecord( + id: id, + createdAt: createdAt, + audioFilePath: audioFilePath, + duration: duration + ) + } +} diff --git a/Domain/Testing/Entities/Stubs/Waveform+Stub.swift b/Domain/Testing/Entities/Stubs/Waveform+Stub.swift new file mode 100644 index 00000000..b4e18690 --- /dev/null +++ b/Domain/Testing/Entities/Stubs/Waveform+Stub.swift @@ -0,0 +1,10 @@ +@testable import Domain +import Foundation + +public extension Waveform { + static func stub( + amplitudes: [Float] = [0.1, 0.2] + ) -> Waveform { + Waveform(amplitudes: amplitudes) + } +} diff --git a/Domain/Testing/Interfaces/Mocks/Authority/MockCheckFirstLaunchRepository.swift b/Domain/Testing/Interfaces/Mocks/Authority/MockCheckFirstLaunchRepository.swift new file mode 100644 index 00000000..3d0b24b5 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/Authority/MockCheckFirstLaunchRepository.swift @@ -0,0 +1,57 @@ +@testable import Domain +import Foundation +import XCTest + +public final class MockCheckFirstLaunchRepository: CheckFirstLaunchRepository, @unchecked Sendable { + public init() {} + + private var returnValue: Bool = false + private var checkIsFirstLaunchCallCount = 0 + private var checkAndMarkFirstLaunchCallCount = 0 + private var expectedCallCount: Int? + private var expectedCheckIsFirstLaunchCallCount: Int? + + public func setReturnValue(_ value: Bool) { + returnValue = value + } + + public func expectCheckAndMarkFirstLaunch(callCount: Int) { + expectedCallCount = callCount + } + + public func expectCheckIsFirstLaunch(callCount: Int) { + expectedCheckIsFirstLaunchCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + if let expected = expectedCallCount { + XCTAssertEqual( + checkAndMarkFirstLaunchCallCount, + expected, + "첫 실행 확인 및 마킹 호출 횟수가 일치하지 않습니다.", + file: file, + line: line + ) + } + if let expectedCheckIs = expectedCheckIsFirstLaunchCallCount { + XCTAssertEqual( + checkIsFirstLaunchCallCount, + expectedCheckIs, + "첫 실행 확인(단순 조회) 호출 횟수가 일치하지 않습니다.", + file: file, + line: line + ) + } + } + + public func checkIsFirstLaunch() -> Bool { + checkIsFirstLaunchCallCount += 1 + return returnValue + } + + public func checkAndMarkFirstLaunch() -> Bool { + checkAndMarkFirstLaunchCallCount += 1 + + return returnValue + } +} diff --git a/Domain/Testing/Interfaces/Mocks/Folders/MockFolderRepository.swift b/Domain/Testing/Interfaces/Mocks/Folders/MockFolderRepository.swift new file mode 100644 index 00000000..87086274 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/Folders/MockFolderRepository.swift @@ -0,0 +1,269 @@ +@testable import Domain +import XCTest + +@MainActor +public final class MockFolderRepository: FolderRepository, @unchecked Sendable { + // Results + private var createResult: Result? + private var fetchAllResult: Result<[Folder], FolderRepositoryError>? + private var fetchByIDResult: Result? + private var fetchByKindResults: [FolderKind: Result<[Folder], FolderRepositoryError>] = [:] + private var updateResult: Result? + private var observeByKindResults: [FolderKind: Result, FolderRepositoryError>] = [:] + private var observeTrashedResult: Result, FolderRepositoryError>? + + // 호출 검증 Count + private var createCallCount = 0 + private var fetchAllCallCount = 0 + private var fetchByIDCallCount = 0 + private var updateCallCount = 0 + private var deleteCallCount = 0 + + // 인자 검증 + private var actualCreatedFolder: Folder? + private var actualFolder: Folder? + private var actualFetchByID: UUID? + + // Expected Values + private var expectedCreateCallCount: Int? + private var expectedFetchAllCallCount: Int? + private var expectedFetchByIDCallCount: Int? + private var expectedUpdateCallCount: Int? + private var expectedDeleteCallCount: Int? + + private var expectedCreateName: String? + private var expectedCreateKind: FolderKind? + private var expectedFolderID: UUID? + private var expectedFetchByID: UUID? + + public init() {} + + // MARK: - Setup + + public func setCreateResult(_ result: Result) { + createResult = result + } + + public func setFetchAllResult(_ result: Result<[Folder], FolderRepositoryError>) { + fetchAllResult = result + } + + public func setFetchByIDResult(_ result: Result) { + fetchByIDResult = result + } + + public func setFetchByKindResult(_ kind: FolderKind, result: Result<[Folder], FolderRepositoryError>) { + fetchByKindResults[kind] = result + } + + public func setUpdateResult(_ result: Result) { + updateResult = result + } + + public func setObserveByKindResult( + _ kind: FolderKind, + result: Result, FolderRepositoryError> + ) { + observeByKindResults[kind] = result + } + + public func setObserveTrashedResult(_ result: Result, FolderRepositoryError>) { + observeTrashedResult = result + } + + // MARK: - Expectations + + public func expectCreate(name: String? = nil, kind: FolderKind? = nil, callCount: Int) { + expectedCreateName = name + expectedCreateKind = kind + expectedCreateCallCount = callCount + } + + public func expectFetchAll(callCount: Int) { + expectedFetchAllCallCount = callCount + } + + public func expectFetchByID(id: UUID? = nil, callCount: Int) { + expectedFetchByID = id + expectedFetchByIDCallCount = callCount + } + + public func expectUpdate(folderID: UUID? = nil, callCount: Int) { + expectedFolderID = folderID + expectedUpdateCallCount = callCount + } + + public func expectDelete(callCount: Int) { + expectedDeleteCallCount = callCount + } + + // MARK: - Verification + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + if let expected = expectedCreateCallCount { + XCTAssertEqual( + createCallCount, expected, "생성 호출 횟수가 일치하지 않습니다.", file: file, line: line + ) + } + + if let expectedCreateName { + XCTAssertEqual( + actualCreatedFolder?.name, + expectedCreateName, + "생성 이름 인자가 일치하지 않습니다.", + file: file, + line: line + ) + } + + if let expectedCreateKind { + XCTAssertEqual( + actualCreatedFolder?.kind, + expectedCreateKind, + "생성 폴더 kind 인자가 일치하지 않습니다.", + file: file, + line: line + ) + } + + if let expected = expectedFetchAllCallCount { + XCTAssertEqual( + fetchAllCallCount, expected, "전체 조회 호출 횟수가 일치하지 않습니다.", file: file, line: line + ) + } + + if let expected = expectedFetchByIDCallCount { + XCTAssertEqual( + fetchByIDCallCount, expected, "ID 조회 호출 횟수가 일치하지 않습니다.", file: file, line: line + ) + } + + if let expectedID = expectedFetchByID { + XCTAssertEqual( + actualFetchByID, expectedID, "ID 조회 인자가 일치하지 않습니다.", file: file, line: line + ) + } + + if let expected = expectedUpdateCallCount { + XCTAssertEqual( + updateCallCount, expected, "수정 호출 횟수가 일치하지 않습니다.", file: file, line: line + ) + } + if let expectedID = expectedFolderID { + XCTAssertEqual( + actualFolder?.id, expectedID, "수정 폴더 ID가 일치하지 않습니다.", file: file, line: line + ) + } + + if let expected = expectedDeleteCallCount { + XCTAssertEqual( + deleteCallCount, expected, "삭제 호출 횟수가 일치하지 않습니다.", file: file, line: line + ) + } + } + + // MARK: - FolderRepository + + public func create(_ folder: Folder) throws(FolderRepositoryError) -> Folder { + createCallCount += 1 + actualCreatedFolder = folder + + switch createResult { + case .success(let folder): + return folder + case .failure(let error): + throw error + case .none: + XCTFail("MockFolderRepository.createResult가 설정되지 않았습니다.") + let error = NSError(domain: "MockFolderRepository.createResult", code: 0) + throw .unknown(error) + } + } + + public func fetch(by id: UUID) throws(FolderRepositoryError) -> Folder { + fetchByIDCallCount += 1 + actualFetchByID = id + + switch fetchByIDResult { + case .success(let folder): + return folder + case .failure(let error): + throw error + case .none: + XCTFail("MockFolderRepository.fetchByIDResult가 설정되지 않았습니다.") + let error = NSError(domain: "MockFolderRepository.fetchByIDResult", code: 0) + throw .unknown(error) + } + } + + public func fetch(by kind: FolderKind) throws(FolderRepositoryError) -> [Folder] { + switch fetchByKindResults[kind] { + case .success(let folders): + return folders + case .failure(let error): + throw error + case .none: + XCTFail("MockFolderRepository.fetchByKindResults[\(kind)]가 설정되지 않았습니다.") + let error = NSError(domain: "MockFolderRepository.fetchByKindResults", code: 0) + throw .unknown(error) + } + } + + public func fetchAll() throws(FolderRepositoryError) -> [Folder] { + fetchAllCallCount += 1 + + switch fetchAllResult { + case .success(let folders): + return folders + case .failure(let error): + throw error + case .none: + XCTFail("MockFolderRepository.fetchAll이 설정되지 않았습니다.") + let error = NSError(domain: "MockFolderRepository.fetchAllResult", code: 0) + throw .unknown(error) + } + } + + public func update(_ folder: Folder) throws(FolderRepositoryError) -> Folder { + updateCallCount += 1 + actualFolder = folder + + switch updateResult { + case .success(let updatedFolder): + return updatedFolder + case .failure(let error): + throw error + case .none: + XCTFail("MockFolderRepository.updateResult가 설정되지 않았습니다.") + let error = NSError(domain: "MockFolderRepository.updateResult", code: 0) + throw .unknown(error) + } + } + + public func observe(by kind: FolderKind) throws(FolderRepositoryError) -> AsyncStream<[Folder]> { + switch observeByKindResults[kind] { + case .success(let stream): + return stream + case .failure(let error): + throw error + case .none: + XCTFail("MockFolderRepository.observeByKindResults[\(kind)]가 설정되지 않았습니다.") + let error = NSError(domain: "MockFolderRepository.observeByKindResults", code: 0) + throw .unknown(error) + } + } + + // MARK: - Trash operations (no-op defaults; override via test helpers if needed) + + public func observeTrashed() throws(FolderRepositoryError) -> AsyncStream<[Folder]> { + switch observeTrashedResult { + case .success(let stream): return stream + case .failure(let error): throw error + case .none: return AsyncStream { $0.finish() } + } + } + + public func delete(id _: UUID) throws(FolderRepositoryError) { + deleteCallCount += 1 + } +} diff --git a/Domain/Testing/Interfaces/Mocks/Languages/MockLanguageRepository.swift b/Domain/Testing/Interfaces/Mocks/Languages/MockLanguageRepository.swift new file mode 100644 index 00000000..a9107899 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/Languages/MockLanguageRepository.swift @@ -0,0 +1,69 @@ +@testable import Domain +import Foundation +import XCTest + +public final class MockLanguageRepository: LanguageRepository, @unchecked Sendable { + public init() {} + + private var fetchResult: Language? + private var fetchCallCount = 0 + private var saveCallCount = 0 + private var lastSavedLanguage: Language? + + private var expectedFetchCallCount: Int? + private var expectedSaveCallCount: Int? + private var expectedLastSavedLanguage: Language? + + public func setFetchResult(_ language: Language) { + fetchResult = language + } + + public func expectFetch(callCount: Int) { + expectedFetchCallCount = callCount + } + + public func expectSave(language: Language? = nil, callCount: Int) { + expectedSaveCallCount = callCount + expectedLastSavedLanguage = language + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + if let expected = expectedFetchCallCount { + XCTAssertEqual( + fetchCallCount, + expected, + "조회 호출 횟수가 일치하지 않습니다.", + file: file, + line: line + ) + } + if let expected = expectedSaveCallCount { + XCTAssertEqual( + saveCallCount, + expected, + "저장 호출 횟수가 일치하지 않습니다.", + file: file, + line: line + ) + } + if let expected = expectedLastSavedLanguage { + XCTAssertEqual( + lastSavedLanguage, + expected, + "마지막으로 저장된 언어가 일치하지 않습니다.", + file: file, + line: line + ) + } + } + + public func fetchLanguage() -> Language { + fetchCallCount += 1 + return fetchResult ?? .ko + } + + public func saveLanguage(_ language: Language) { + saveCallCount += 1 + lastSavedLanguage = language + } +} diff --git a/Domain/Testing/Interfaces/Mocks/MLXSupport/MockAvailableModelSupportRepository.swift b/Domain/Testing/Interfaces/Mocks/MLXSupport/MockAvailableModelSupportRepository.swift new file mode 100644 index 00000000..93e52167 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/MLXSupport/MockAvailableModelSupportRepository.swift @@ -0,0 +1,107 @@ +@testable import Domain +import Foundation +import XCTest + +@MainActor +public final class MockAvailableModelSupportRepository: AvailableModelSupportRepository { + public init() {} + + private var checkSupportModelResult: ChaGokModelSupport? + private var downloadModelResult: Result? + private var fetchSupportModelsResult: [ChaGokModelState] = [] + + private var actualCheckSupportModelCallCount = 0 + private var actualDownloadModelCallCount = 0 + private var actualFetchSupportModelsCallCount = 0 + + private var expectedCheckSupportModelCallCount: Int? + private var expectedDownloadModelCallCount: Int? + private var expectedFetchSupportModelsCallCount: Int? + + public func setCheckSupportModelResult(_ result: ChaGokModelSupport) { + checkSupportModelResult = result + } + + public func setDownloadModelResult(_ result: Result) { + downloadModelResult = result + } + + public func setFetchSupportModelsResult(_ result: [ChaGokModelState]) { + fetchSupportModelsResult = result + } + + public func expectCheckSupportModel(callCount: Int) { + expectedCheckSupportModelCallCount = callCount + } + + public func expectDownloadModel(callCount: Int) { + expectedDownloadModelCallCount = callCount + } + + public func expectFetchSupportModels(callCount: Int) { + expectedFetchSupportModelsCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + assertCount( + actualCheckSupportModelCallCount, + expectedCheckSupportModelCallCount, + "checkSupportModel", + file, + line + ) + assertCount(actualDownloadModelCallCount, expectedDownloadModelCallCount, "downloadModel", file, line) + assertCount( + actualFetchSupportModelsCallCount, + expectedFetchSupportModelsCallCount, + "fetchSupportModels", + file, + line + ) + } + + private func assertCount( + _ actual: Int, + _ expected: Int?, + _ label: String, + _ file: StaticString, + _ line: UInt + ) { + guard let expected else { return } + XCTAssertEqual(actual, expected, "\(label) 호출 횟수 불일치", file: file, line: line) + } + + public func checkMLXSupportModel() async -> ChaGokModelSupport { + actualCheckSupportModelCallCount += 1 + if let result = checkSupportModelResult { + return result + } + XCTFail("MockAvailableModelSupportRepository.checkSupportModelResult 미설정") + return ChaGokModelSupport(ramSizeGB: 0) + } + + public func downloadModel( + progressHandler: @Sendable @escaping (Progress) -> Void + ) async throws(AvailableModelSupportRepositoryError) { + if Task.isCancelled { throw .cancelled } + actualDownloadModelCallCount += 1 + + switch downloadModelResult { + case .success: + let progress = Progress(totalUnitCount: 100) + progress.completedUnitCount = 100 + progressHandler(progress) + return + case .failure(let error): + throw error + case .none: + XCTFail("MockAvailableModelSupportRepository.downloadModelResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func fetchSupportModels() async -> [Domain.ChaGokModelState] { + actualFetchSupportModelsCallCount += 1 + return fetchSupportModelsResult + } +} diff --git a/Domain/Testing/Interfaces/Mocks/MLXSupport/MockDefaultMLXSummaryRepository.swift b/Domain/Testing/Interfaces/Mocks/MLXSupport/MockDefaultMLXSummaryRepository.swift new file mode 100644 index 00000000..2599bf7f --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/MLXSupport/MockDefaultMLXSummaryRepository.swift @@ -0,0 +1,53 @@ +@testable import Domain +import Foundation +import XCTest + +public actor MockDefaultMLXSummaryRepository: SummaryRepository { + public init() {} + + private var summarizeResult: Result<(keywords: [Keyword], summary: Summary), SummaryRepositoryError>? + + private var actualSummarizeCallCount = 0 + private var expectedSummarizeCallCount: Int? + + public func setSummarizeResult(_ result: Result<(keywords: [Keyword], summary: Summary), SummaryRepositoryError>) { + summarizeResult = result + } + + public func expectSummarize(callCount: Int) { + expectedSummarizeCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + assertCount(actualSummarizeCallCount, expectedSummarizeCallCount, "summarize", file, line) + } + + private func assertCount( + _ actual: Int, + _ expected: Int?, + _ label: String, + _ file: StaticString, + _ line: UInt + ) { + guard let expected else { return } + XCTAssertEqual(actual, expected, "\(label) 호출 횟수 불일치", file: file, line: line) + } + + public func summarize( + transcript: Transcript, + language: Language + ) async throws(SummaryRepositoryError) -> (keywords: [Keyword], summary: Summary) { + if Task.isCancelled { throw .cancelled } + actualSummarizeCallCount += 1 + + switch summarizeResult { + case .success(let value): + return value + case .failure(let error): + throw error + case .none: + XCTFail("MockDefaultMLXSummaryRepository.summarizeResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } +} diff --git a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift new file mode 100644 index 00000000..c68b6169 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift @@ -0,0 +1,48 @@ +@testable import Domain +import Foundation +import XCTest + +@MainActor +public final class MockOnDeviceRepository: OnDeviceRepository { + public init() {} + + public var downloadResult: Result = .success(()) + public var deleteResult: Result = .success(OnDeviceStatus( + storage: .notDownloaded + )) + public var checkStatusResult: OnDeviceStatus = OnDeviceStatus(storage: .notDownloaded) + + public var downloadProgressValues: [Double] = [] + + public var actualDownloadCallCount = 0 + public var actualDeleteCallCount = 0 + public var actualCheckStatusCallCount = 0 + + public func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { + actualDownloadCallCount += 1 + for val in downloadProgressValues { + progressHandler(val) + } + switch downloadResult { + case .success: + return + case .failure(let error): + throw error + } + } + + public func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus { + actualDeleteCallCount += 1 + switch deleteResult { + case .success(let status): + return status + case .failure(let error): + throw error + } + } + + public func checkStatus() async -> OnDeviceStatus { + actualCheckStatusCallCount += 1 + return checkStatusResult + } +} diff --git a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift new file mode 100644 index 00000000..37e820c1 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift @@ -0,0 +1,61 @@ +@testable import Domain +import Foundation +import XCTest + +public final class MockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked Sendable { + public init() {} + + public var subscribeStream: AsyncStream? + public var downloadResult: Result = .success(()) + public var deleteResult: Result = .success(OnDeviceStatus( + storage: .notDownloaded + )) + + public var actualSubscribeCallCount = 0 + public var actualDownloadCallCount = 0 + public var actualDeleteCallCount = 0 + public var actualCheckStatusCallCount = 0 + + public var subscribedModel: ChaGokModel? + public var downloadedModel: ChaGokModel? + public var deletedModel: ChaGokModel? + public var checkedModel: ChaGokModel? + + public var checkStatusResult: OnDeviceStatus = OnDeviceStatus( + storage: .notDownloaded + ) + + public func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { + actualCheckStatusCallCount += 1 + checkedModel = model + return checkStatusResult + } + + public func subscribe(model: ChaGokModel) async -> AsyncStream { + actualSubscribeCallCount += 1 + subscribedModel = model + return subscribeStream ?? AsyncStream { $0.finish() } + } + + public func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) { + actualDownloadCallCount += 1 + downloadedModel = model + switch downloadResult { + case .success: + return + case .failure(let error): + throw error + } + } + + public func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) { + actualDeleteCallCount += 1 + deletedModel = model + switch deleteResult { + case .success: + return + case .failure(let error): + throw error + } + } +} diff --git a/Domain/Testing/Interfaces/Mocks/VoiceNote/MockSTTRepository.swift b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockSTTRepository.swift new file mode 100644 index 00000000..8ce4b4cc --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockSTTRepository.swift @@ -0,0 +1,154 @@ +@testable import Domain +import Foundation +import XCTest + +public actor MockSTTRepository: STTRepository { + public init() {} + + private var result: Result? + private nonisolated(unsafe) var checkResult: PermissionStatus? + private var requestResult: Result? + private var downloadResult: Result? + + private var actualCallCount = 0 + private var actualAudioFilePath: String? + private nonisolated(unsafe) var actualCheckSTTPermissionCallCount = 0 + private var actualRequestSTTPermissionCallCount = 0 + private var actualDownloadCallCount = 0 + + private var expectedCallCount: Int? + private var expectedAudioFilePath: String? + private nonisolated(unsafe) var expectedCheckSTTPermissionCallCount: Int? + private var expectedRequestSTTPermissionCallCount: Int? + private var expectedDownloadCallCount: Int? + + public func setResult(_ result: Result) { + self.result = result + } + + public func setCheckResult(_ result: PermissionStatus) { + checkResult = result + } + + public func setRequestResult(_ result: Result) { + requestResult = result + } + + public func setDownloadResult(_ result: Result) { + downloadResult = result + } + + public func expectTranscribe(callCount: Int, audioFilePath: String? = nil) { + expectedCallCount = callCount + expectedAudioFilePath = audioFilePath + } + + public func expectCheckSTTPermission(callCount: Int) { + expectedCheckSTTPermissionCallCount = callCount + } + + public func expectRequestSTTPermission(callCount: Int) { + expectedRequestSTTPermissionCallCount = callCount + } + + public func expectDownload(callCount: Int) { + expectedDownloadCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + if let expected = expectedCallCount { + XCTAssertEqual( + actualCallCount, expected, "변환 호출 횟수가 일치하지 않습니다.", file: file, line: line + ) + } + if let expectedPath = expectedAudioFilePath { + XCTAssertEqual( + actualAudioFilePath, expectedPath, "변환 오디오 파일 경로가 일치하지 않습니다.", file: file, line: line + ) + } + if let expected = expectedCheckSTTPermissionCallCount { + XCTAssertEqual( + actualCheckSTTPermissionCallCount, + expected, + "STT 권한 확인 호출 횟수가 일치하지 않습니다.", + file: file, + line: line + ) + } + if let expected = expectedRequestSTTPermissionCallCount { + XCTAssertEqual( + actualRequestSTTPermissionCallCount, + expected, + "STT 권한 요청 호출 횟수가 일치하지 않습니다.", + file: file, + line: line + ) + } + if let expected = expectedDownloadCallCount { + XCTAssertEqual( + actualDownloadCallCount, + expected, + "다운로드 호출 횟수가 일치하지 않습니다.", + file: file, + line: line + ) + } + } + + public func transcribe(audioFilePath: String) async throws(STTRepositoryError) -> Transcript { + actualCallCount += 1 + actualAudioFilePath = audioFilePath + + switch result { + case .success(let value): + return value + case .failure(let error): + throw error + case .none: + XCTFail("MockSTTRepository.result 가 설정되지 않았습니다.") + throw .unknown( + NSError(domain: "MockSTTRepository.result", code: -1) + ) + } + } + + public nonisolated func checkSTTPermission() -> PermissionStatus { + actualCheckSTTPermissionCallCount += 1 + if let checkResult { + return checkResult + } + XCTFail("MockSTTRepository.checkResult 가 설정되지 않았습니다.") + return .notDetermined + } + + public func requestSTTPermission() async throws(STTPermissionRepositoryError) -> PermissionStatus { + actualRequestSTTPermissionCallCount += 1 + + switch requestResult { + case .success(let state): + return state + case .failure(let error): + throw error + case .none: + XCTFail("MockSTTRepository.requestResult 가 설정되지 않았습니다.") + throw .unknown(NSError(domain: "MockSTTRepository.requestResult", code: -1)) + } + } + + @discardableResult + public func download( + progressHandler: (@Sendable (Progress) -> Void)? = nil + ) async throws(STTRepositoryError) -> URL { + actualDownloadCallCount += 1 + + switch downloadResult { + case .success(let url): + return url + case .failure(let error): + throw error + case .none: + XCTFail("MockSTTRepository.downloadResult 가 설정되지 않았습니다.") + throw .unknown(NSError(domain: "MockSTTRepository.downloadResult", code: -1)) + } + } +} diff --git a/Domain/Testing/Interfaces/Mocks/VoiceNote/MockSummaryRepository.swift b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockSummaryRepository.swift new file mode 100644 index 00000000..cebd0373 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockSummaryRepository.swift @@ -0,0 +1,58 @@ +@testable import Domain +import Foundation +import XCTest + +public actor MockSummaryRepository: SummaryRepository { + public init() {} + + private var result: Result<(keywords: [Keyword], summary: Summary), SummaryRepositoryError>? + + private var actualCallCount = 0 + private var actualTranscript: Transcript? + + private var expectedCallCount: Int? + private var expectedTranscriptText: String? + + public func setResult( + _ result: Result<(keywords: [Keyword], summary: Summary), SummaryRepositoryError> + ) { + self.result = result + } + + public func expectSummarize(callCount: Int, transcriptText: String? = nil) { + expectedCallCount = callCount + expectedTranscriptText = transcriptText + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + if let expected = expectedCallCount { + XCTAssertEqual(actualCallCount, expected, "요약 호출 횟수가 일치하지 않습니다.", file: file, line: line) + } + if let expectedText = expectedTranscriptText { + let actualText = actualTranscript?.sections.map(\.text).joined(separator: "\n") + XCTAssertEqual( + actualText, expectedText, "요약 텍스트 내용이 일치하지 않습니다.", file: file, + line: line + ) + } + } + + public func summarize(transcript: Transcript, language: Language) async throws(SummaryRepositoryError) -> ( + keywords: [Keyword], summary: Summary + ) { + actualCallCount += 1 + actualTranscript = transcript + + switch result { + case .success(let value): + return value + case .failure(let error): + throw error + case .none: + XCTFail("MockSummaryRepository.result 가 설정되지 않았습니다.") + throw .unknown( + NSError(domain: "MockSummaryRepository.result", code: -1) + ) + } + } +} diff --git a/Domain/Testing/Interfaces/Mocks/VoiceNote/MockVoiceNoteAnalysisService.swift b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockVoiceNoteAnalysisService.swift new file mode 100644 index 00000000..7deb0f0e --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockVoiceNoteAnalysisService.swift @@ -0,0 +1,76 @@ +@testable import Domain +import Foundation +import XCTest + +@MainActor +public final class MockVoiceNoteAnalysisService: VoiceNoteAnalysisService { + public init() {} + + private var enqueueCallCount = 0 + private var regenerateCallCount = 0 + private var cancelCallCount = 0 + private var cancelAllCallCount = 0 + + private var enqueuedIDs: [UUID] = [] + private var regeneratedIDs: [UUID] = [] + private var cancelledIDs: [UUID] = [] + + private var expectedEnqueueCallCount: Int? + private var expectedRegenerateCallCount: Int? + private var expectedCancelCallCount: Int? + private var expectedCancelAllCallCount: Int? + + // MARK: - Expectations + + public func expectEnqueue(callCount: Int) { + expectedEnqueueCallCount = callCount + } + + public func expectRegenerate(callCount: Int) { + expectedRegenerateCallCount = callCount + } + + public func expectCancel(callCount: Int) { + expectedCancelCallCount = callCount + } + + public func expectCancelAll(callCount: Int) { + expectedCancelAllCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + if let expected = expectedEnqueueCallCount { + XCTAssertEqual(enqueueCallCount, expected, "enqueue 호출 횟수 불일치", file: file, line: line) + } + if let expected = expectedRegenerateCallCount { + XCTAssertEqual(regenerateCallCount, expected, "regenerate 호출 횟수 불일치", file: file, line: line) + } + if let expected = expectedCancelCallCount { + XCTAssertEqual(cancelCallCount, expected, "cancel 호출 횟수 불일치", file: file, line: line) + } + if let expected = expectedCancelAllCallCount { + XCTAssertEqual(cancelAllCallCount, expected, "cancelAll 호출 횟수 불일치", file: file, line: line) + } + } + + // MARK: - Protocol + + public func enqueue(voiceNoteID: UUID) { + enqueueCallCount += 1 + enqueuedIDs.append(voiceNoteID) + } + + public func regenerate(voiceNoteID: UUID) { + regenerateCallCount += 1 + regeneratedIDs.append(voiceNoteID) + } + + public func cancel(voiceNoteID: UUID) { + cancelCallCount += 1 + cancelledIDs.append(voiceNoteID) + } + + public func cancelAll() { + cancelAllCallCount += 1 + } +} diff --git a/Domain/Testing/Interfaces/Mocks/VoiceNote/MockVoiceNoteRepository.swift b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockVoiceNoteRepository.swift new file mode 100644 index 00000000..e67d1936 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/VoiceNote/MockVoiceNoteRepository.swift @@ -0,0 +1,238 @@ +@testable import Domain +import Foundation +import XCTest + +@MainActor +public final class MockVoiceNoteRepository: VoiceNoteRepository { + private var createResult: Result? + private var updateResult: Result? + private var fetchResult: Result? + private var observeResult: Result, VoiceNoteRepositoryError>? + private var observeFolderResult: Result, VoiceNoteRepositoryError>? + private var observeRecentResult: Result, VoiceNoteRepositoryError>? + private var observeTrashedResult: Result, VoiceNoteRepositoryError>? + private var deleteCallCount = 0 + + public init() {} + + // Call Counts + private var createCallCount = 0 + private var updateCallCount = 0 + private var fetchCallCount = 0 + private var observeCallCount = 0 + private var observeFolderCallCount = 0 + private var observeRecentCallCount = 0 + + // Actual Inputs + private var actualCreatedVoiceNote: VoiceNote? + private var actualUpdatedVoiceNote: VoiceNote? + private var actualFetchID: UUID? + private var actualObserveFolderID: UUID? + private var actualObserveRecentLimit: Int? + + // Expected Values + private var expectedCreateCallCount: Int? + private var expectedUpdateCallCount: Int? + private var expectedFetchByIdCallCount: Int? + private var expectedObserveCallCount: Int? + private var expectedObserveFolderCallCount: Int? + private var expectedObserveFolderID: UUID? + private var expectedObserveRecentCallCount: Int? + private var expectedObserveRecentLimit: Int? + + /// Set Results + public func setCreateResult(_ result: Result) { + createResult = result + } + + public func setUpdateResult(_ result: Result) { + updateResult = result + } + + public func setFetchResult(_ result: Result) { + fetchResult = result + } + + public func setObserveResult(_ result: Result, VoiceNoteRepositoryError>) { + observeResult = result + } + + public func setObserveFolderResult(_ result: Result, VoiceNoteRepositoryError>) { + observeFolderResult = result + } + + public func setObserveRecentResult(_ result: Result, VoiceNoteRepositoryError>) { + observeRecentResult = result + } + + public func setObserveTrashedResult(_ result: Result, VoiceNoteRepositoryError>) { + observeTrashedResult = result + } + + /// Expect Methods + public func expectCreate(callCount: Int) { + expectedCreateCallCount = callCount + } + + public func expectUpdate(callCount: Int) { + expectedUpdateCallCount = callCount + } + + public func expectFetchById(callCount: Int) { + expectedFetchByIdCallCount = callCount + } + + public func expectObserve(callCount: Int) { + expectedObserveCallCount = callCount + } + + public func expectObserveFolder(callCount: Int, folderID: UUID? = nil) { + expectedObserveFolderCallCount = callCount + expectedObserveFolderID = folderID + } + + public func expectObserveRecent(callCount: Int, limit: Int? = nil) { + expectedObserveRecentCallCount = callCount + expectedObserveRecentLimit = limit + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + if let exp = expectedCreateCallCount { XCTAssertEqual( + createCallCount, + exp, + "create 호출 횟수 불일치", + file: file, + line: line + ) } + if let exp = expectedUpdateCallCount { XCTAssertEqual( + updateCallCount, + exp, + "update 호출 횟수 불일치", + file: file, + line: line + ) } + if let exp = expectedFetchByIdCallCount { XCTAssertEqual( + fetchCallCount, + exp, + "fetch(byId:) 호출 횟수 불일치", + file: file, + line: line + ) } + if let exp = expectedObserveCallCount { XCTAssertEqual( + observeCallCount, + exp, + "observe 호출 횟수 불일치", + file: file, + line: line + ) } + if let exp = expectedObserveFolderCallCount { XCTAssertEqual( + observeFolderCallCount, + exp, + "observe(folderID:) 호출 횟수 불일치", + file: file, + line: line + ) } + if let expID = expectedObserveFolderID { XCTAssertEqual( + actualObserveFolderID, + expID, + "observe folderID 불일치", + file: file, + line: line + ) } + if let exp = expectedObserveRecentCallCount { XCTAssertEqual( + observeRecentCallCount, + exp, + "observeRecent 호출 횟수 불일치", + file: file, + line: line + ) } + if let expLimit = expectedObserveRecentLimit { XCTAssertEqual( + actualObserveRecentLimit, + expLimit, + "observeRecent limit 불일치", + file: file, + line: line + ) } + } + + // Repository Implementations + + public func create(_ voiceNote: VoiceNote) throws(VoiceNoteRepositoryError) -> VoiceNote { + createCallCount += 1 + actualCreatedVoiceNote = voiceNote + switch createResult { + case .success(let val): return val + case .failure(let err): throw err + case .none: XCTFail("createResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func update(_ voiceNote: VoiceNote) throws(VoiceNoteRepositoryError) -> VoiceNote { + updateCallCount += 1 + actualUpdatedVoiceNote = voiceNote + switch updateResult { + case .success(let val): return val + case .failure(let err): throw err + case .none: XCTFail("updateResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func fetch(byId id: UUID) throws(VoiceNoteRepositoryError) -> VoiceNote { + fetchCallCount += 1 + actualFetchID = id + switch fetchResult { + case .success(let val): return val + case .failure(let err): throw err + case .none: XCTFail("fetchResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func observe(id: UUID) throws(VoiceNoteRepositoryError) -> AsyncStream { + observeCallCount += 1 + switch observeResult { + case .success(let stream): return stream + case .failure(let err): throw err + case .none: XCTFail("observeResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func observe(folderID: UUID) throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> { + observeFolderCallCount += 1 + actualObserveFolderID = folderID + switch observeFolderResult { + case .success(let stream): return stream + case .failure(let err): throw err + case .none: XCTFail("observeFolderResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func observeRecent(limit: Int) throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> { + observeRecentCallCount += 1 + actualObserveRecentLimit = limit + switch observeRecentResult { + case .success(let stream): return stream + case .failure(let err): throw err + case .none: XCTFail("observeRecentResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + // MARK: - Trash operations (no-op defaults; override via test helpers if needed) + + public func observeTrashed() throws(VoiceNoteRepositoryError) -> AsyncStream<[VoiceNote]> { + switch observeTrashedResult { + case .success(let stream): return stream + case .failure(let error): throw error + case .none: return AsyncStream { $0.finish() } + } + } + + public func delete(id _: UUID) throws(VoiceNoteRepositoryError) { + deleteCallCount += 1 + } +} diff --git a/Domain/Testing/Interfaces/Mocks/VoiceRecords/MockVoiceRecordPlaybackRepository.swift b/Domain/Testing/Interfaces/Mocks/VoiceRecords/MockVoiceRecordPlaybackRepository.swift new file mode 100644 index 00000000..bd1791da --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/VoiceRecords/MockVoiceRecordPlaybackRepository.swift @@ -0,0 +1,149 @@ +@testable import Domain +import XCTest + +@MainActor +public final class MockVoiceRecordPlaybackRepository: VoiceRecordPlaybackRepository { + public init() {} + + private var prepareResult: Result, VoiceRecordPlaybackRepositoryError>? + private var playResult: Result? + private var pauseResult: Result? + private var seekResult: Result? + private var stopResult: Result? + + private var actualPrepareCallCount = 0 + private var actualPlayCallCount = 0 + private var actualPauseCallCount = 0 + private var actualSeekCallCount = 0 + private var actualStopCallCount = 0 + + private var expectedPrepareCallCount: Int? + private var expectedPlayCallCount: Int? + private var expectedPauseCallCount: Int? + private var expectedSeekCallCount: Int? + private var expectedStopCallCount: Int? + + public private(set) var preparedAudioFilePath: String? + public private(set) var lastSeekTime: TimeInterval? + + public func setPrepareResult(_ result: Result< + AsyncStream, + VoiceRecordPlaybackRepositoryError + >) { + prepareResult = result + } + + public func setPlayResult(_ result: Result) { + playResult = result + } + + public func setPauseResult(_ result: Result) { + pauseResult = result + } + + public func setSeekResult(_ result: Result) { + seekResult = result + } + + public func setStopResult(_ result: Result) { + stopResult = result + } + + public func expectPrepare(callCount: Int) { + expectedPrepareCallCount = callCount + } + + public func expectPlay(callCount: Int) { + expectedPlayCallCount = callCount + } + + public func expectPause(callCount: Int) { + expectedPauseCallCount = callCount + } + + public func expectSeek(callCount: Int) { + expectedSeekCallCount = callCount + } + + public func expectStop(callCount: Int) { + expectedStopCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + assertCount(actualPrepareCallCount, expectedPrepareCallCount, "prepare", file, line) + assertCount(actualPlayCallCount, expectedPlayCallCount, "play", file, line) + assertCount(actualPauseCallCount, expectedPauseCallCount, "pause", file, line) + assertCount(actualSeekCallCount, expectedSeekCallCount, "seek", file, line) + assertCount(actualStopCallCount, expectedStopCallCount, "stop", file, line) + } + + private func assertCount( + _ actual: Int, + _ expected: Int?, + _ label: String, + _ file: StaticString, + _ line: UInt + ) { + guard let expected else { return } + XCTAssertEqual(actual, expected, "\(label) 호출 횟수 불일치", file: file, line: line) + } + + public func prepare(audioFilePath: String) throws(VoiceRecordPlaybackRepositoryError) + -> AsyncStream + { + actualPrepareCallCount += 1 + preparedAudioFilePath = audioFilePath + switch prepareResult { + case .success(let stream): return stream + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordPlaybackRepository.prepareResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func play() throws(VoiceRecordPlaybackRepositoryError) { + actualPlayCallCount += 1 + switch playResult { + case .success: return + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordPlaybackRepository.playResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func pause() throws(VoiceRecordPlaybackRepositoryError) { + actualPauseCallCount += 1 + switch pauseResult { + case .success: return + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordPlaybackRepository.pauseResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func seek(to time: TimeInterval) throws(VoiceRecordPlaybackRepositoryError) { + actualSeekCallCount += 1 + lastSeekTime = time + switch seekResult { + case .success: return + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordPlaybackRepository.seekResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func stop() throws(VoiceRecordPlaybackRepositoryError) { + actualStopCallCount += 1 + switch stopResult { + case .success: return + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordPlaybackRepository.stopResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } +} diff --git a/Domain/Testing/Interfaces/Mocks/VoiceRecords/MockVoiceRecordRepository.swift b/Domain/Testing/Interfaces/Mocks/VoiceRecords/MockVoiceRecordRepository.swift new file mode 100644 index 00000000..0792f7fe --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/VoiceRecords/MockVoiceRecordRepository.swift @@ -0,0 +1,195 @@ +@testable import Domain +import Core +import XCTest + +public actor MockVoiceRecordRepository: VoiceRecordRepository { + public init() {} + + private var startResult: Result, VoiceRecordRepositoryError>? + private var pauseResult: Result? + private var resumeResult: Result? + private var finishResult: Result? + private var cancelResult: Result? + private nonisolated(unsafe) var checkPermissionResult: PermissionStatus? + private var requestPermissionResult: Result? + + private var actualStartRecordingCallCount = 0 + private var actualPauseRecordingCallCount = 0 + private var actualResumeRecordingCallCount = 0 + private var actualFinishRecordingCallCount = 0 + private var actualCancelRecordingCallCount = 0 + private nonisolated(unsafe) var actualCheckPermissionCallCount = 0 + private var actualRequestPermissionCallCount = 0 + + private var expectedStartRecordingCallCount: Int? + private var expectedPauseRecordingCallCount: Int? + private var expectedResumeRecordingCallCount: Int? + private var expectedFinishRecordingCallCount: Int? + private var expectedCancelRecordingCallCount: Int? + private nonisolated(unsafe) var expectedCheckPermissionCallCount: Int? + private var expectedRequestPermissionCallCount: Int? + + public func setStartResult(_ result: Result, VoiceRecordRepositoryError>) { + startResult = result + } + + public func setPauseResult(_ result: Result) { + pauseResult = result + } + + public func setResumeResult(_ result: Result) { + resumeResult = result + } + + public func setFinishResult(_ result: Result) { + finishResult = result + } + + public func setCancelResult(_ result: Result) { + cancelResult = result + } + + public func setCheckPermissionResult(_ result: PermissionStatus) { + checkPermissionResult = result + } + + public func setRequestPermissionResult(_ result: Result) { + requestPermissionResult = result + } + + public func expectStartRecording(callCount: Int) { + expectedStartRecordingCallCount = callCount + } + + public func expectPauseRecording(callCount: Int) { + expectedPauseRecordingCallCount = callCount + } + + public func expectResumeRecording(callCount: Int) { + expectedResumeRecordingCallCount = callCount + } + + public func expectFinishRecording(callCount: Int) { + expectedFinishRecordingCallCount = callCount + } + + public func expectCancelRecording(callCount: Int) { + expectedCancelRecordingCallCount = callCount + } + + public func expectCheckPermission(callCount: Int) { + expectedCheckPermissionCallCount = callCount + } + + public func expectRequestPermission(callCount: Int) { + expectedRequestPermissionCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + assertCount(actualStartRecordingCallCount, expectedStartRecordingCallCount, "startRecording", file, line) + assertCount(actualPauseRecordingCallCount, expectedPauseRecordingCallCount, "pauseRecording", file, line) + assertCount(actualResumeRecordingCallCount, expectedResumeRecordingCallCount, "resumeRecording", file, line) + assertCount(actualFinishRecordingCallCount, expectedFinishRecordingCallCount, "finishRecording", file, line) + assertCount(actualCancelRecordingCallCount, expectedCancelRecordingCallCount, "cancelRecording", file, line) + assertCount( + actualCheckPermissionCallCount, expectedCheckPermissionCallCount, + "checkMicrophonePermission", file, line + ) + assertCount( + actualRequestPermissionCallCount, expectedRequestPermissionCallCount, + "requestMicrophonePermission", file, line + ) + } + + private func assertCount( + _ actual: Int, + _ expected: Int?, + _ label: String, + _ file: StaticString, + _ line: UInt + ) { + guard let expected else { return } + XCTAssertEqual(actual, expected, "\(label) 호출 횟수 불일치", file: file, line: line) + } + + public func startRecording() async throws(VoiceRecordRepositoryError) -> AsyncStream { + if Task.isCancelled { throw .cancelled } + actualStartRecordingCallCount += 1 + switch startResult { + case .success(let stream): return stream + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordRepository.startResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func pauseRecording() async throws(VoiceRecordRepositoryError) { + if Task.isCancelled { throw .cancelled } + actualPauseRecordingCallCount += 1 + switch pauseResult { + case .success: return + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordRepository.pauseResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func resumeRecording() async throws(VoiceRecordRepositoryError) { + if Task.isCancelled { throw .cancelled } + actualResumeRecordingCallCount += 1 + switch resumeResult { + case .success: return + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordRepository.resumeResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func finishRecording() async throws(VoiceRecordRepositoryError) -> VoiceRecord { + if Task.isCancelled { throw .cancelled } + actualFinishRecordingCallCount += 1 + switch finishResult { + case .success(let record): return record + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordRepository.finishResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public func cancelRecording() async throws(VoiceRecordRepositoryError) { + if Task.isCancelled { throw .cancelled } + actualCancelRecordingCallCount += 1 + switch cancelResult { + case .success: return + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordRepository.cancelResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public nonisolated func checkMicrophonePermission() -> PermissionStatus { + actualCheckPermissionCallCount += 1 + if let checkPermissionResult { + return checkPermissionResult + } + XCTFail("MockVoiceRecordRepository.checkPermissionResult 미설정") + return .notDetermined + } + + public func requestMicrophonePermission() async throws(VoiceRecordRepositoryError) -> PermissionStatus { + if Task.isCancelled { throw .cancelled } + actualRequestPermissionCallCount += 1 + switch requestPermissionResult { + case .success(let state): return state + case .failure(let error): throw error + case .none: + XCTFail("MockVoiceRecordRepository.requestPermissionResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } +} diff --git a/Domain/Testing/Interfaces/Mocks/Whisper/MockDefaultWhisperSTTRepository.swift b/Domain/Testing/Interfaces/Mocks/Whisper/MockDefaultWhisperSTTRepository.swift new file mode 100644 index 00000000..75c4a379 --- /dev/null +++ b/Domain/Testing/Interfaces/Mocks/Whisper/MockDefaultWhisperSTTRepository.swift @@ -0,0 +1,141 @@ +@testable import Domain +import Foundation +import XCTest + +public actor MockDefaultWhisperSTTRepository: STTRepository { + public init() {} + + private var transcribeResult: Result? + private nonisolated(unsafe) var checkSTTPermissionResult: PermissionStatus? + private var requestSTTPermissionResult: Result? + private var downloadResult: Result? + + private var actualTranscribeCallCount = 0 + private nonisolated(unsafe) var actualCheckSTTPermissionCallCount = 0 + private var actualRequestSTTPermissionCallCount = 0 + private var actualDownloadCallCount = 0 + + private var expectedTranscribeCallCount: Int? + private nonisolated(unsafe) var expectedCheckSTTPermissionCallCount: Int? + private var expectedRequestSTTPermissionCallCount: Int? + private var expectedDownloadCallCount: Int? + + public func setTranscribeResult(_ result: Result) { + transcribeResult = result + } + + public func setCheckSTTPermissionResult(_ result: PermissionStatus) { + checkSTTPermissionResult = result + } + + public func setRequestSTTPermissionResult(_ result: Result) { + requestSTTPermissionResult = result + } + + public func setDownloadResult(_ result: Result) { + downloadResult = result + } + + public func expectTranscribe(callCount: Int) { + expectedTranscribeCallCount = callCount + } + + public func expectCheckSTTPermission(callCount: Int) { + expectedCheckSTTPermissionCallCount = callCount + } + + public func expectRequestSTTPermission(callCount: Int) { + expectedRequestSTTPermissionCallCount = callCount + } + + public func expectDownload(callCount: Int) { + expectedDownloadCallCount = callCount + } + + public func verify(file: StaticString = #filePath, line: UInt = #line) { + assertCount(actualTranscribeCallCount, expectedTranscribeCallCount, "transcribe", file, line) + assertCount( + actualCheckSTTPermissionCallCount, + expectedCheckSTTPermissionCallCount, + "checkSTTPermission", + file, + line + ) + assertCount( + actualRequestSTTPermissionCallCount, + expectedRequestSTTPermissionCallCount, + "requestSTTPermission", + file, + line + ) + assertCount(actualDownloadCallCount, expectedDownloadCallCount, "download", file, line) + } + + private func assertCount( + _ actual: Int, + _ expected: Int?, + _ label: String, + _ file: StaticString, + _ line: UInt + ) { + guard let expected else { return } + XCTAssertEqual(actual, expected, "\(label) 호출 횟수 불일치", file: file, line: line) + } + + public func transcribe(audioFilePath: String) async throws(STTRepositoryError) -> Transcript { + if Task.isCancelled { throw .cancelled } + actualTranscribeCallCount += 1 + + switch transcribeResult { + case .success(let value): + return value + case .failure(let error): + throw error + case .none: + XCTFail("MockDefaultWhisperSTTRepository.transcribeResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + public nonisolated func checkSTTPermission() -> PermissionStatus { + actualCheckSTTPermissionCallCount += 1 + if let result = checkSTTPermissionResult { + return result + } + XCTFail("MockDefaultWhisperSTTRepository.checkSTTPermissionResult 미설정") + return .notDetermined + } + + public func requestSTTPermission() async throws(STTPermissionRepositoryError) -> PermissionStatus { + if Task.isCancelled { throw .cancelled } + actualRequestSTTPermissionCallCount += 1 + + switch requestSTTPermissionResult { + case .success(let state): + return state + case .failure(let error): + throw error + case .none: + XCTFail("MockDefaultWhisperSTTRepository.requestSTTPermissionResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } + + @discardableResult + public func download( + progressHandler: (@Sendable (Progress) -> Void)? = nil + ) async throws(STTRepositoryError) -> URL { + if Task.isCancelled { throw .cancelled } + actualDownloadCallCount += 1 + + switch downloadResult { + case .success(let url): + return url + case .failure(let error): + throw error + case .none: + XCTFail("MockDefaultWhisperSTTRepository.downloadResult 미설정") + throw .unknown(NSError(domain: "Mock", code: -1)) + } + } +} diff --git a/Domain/Tests/UseCases/Folders/FolderUseCaseTest.swift b/Domain/Tests/UseCases/Folders/FolderUseCaseTest.swift new file mode 100644 index 00000000..2aeba0af --- /dev/null +++ b/Domain/Tests/UseCases/Folders/FolderUseCaseTest.swift @@ -0,0 +1,422 @@ +@testable import Domain +import Core +import DomainTesting +import XCTest + +@MainActor +final class FolderUseCaseTest: XCTestCase {} + +// MARK: - create(name:) 성공 케이스 + +extension FolderUseCaseTest { + func test_정상상태_폴더생성시_생성된폴더를반환한다() throws { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + let expectedName = "New Folder" + let expectedFolder = Folder.stub(name: expectedName) + repository.setCreateResult(.success(expectedFolder)) + repository.expectCreate(name: expectedName, kind: .custom, callCount: 1) + + // When + let folder = try sut.create(name: expectedName) + + // Then + XCTAssertEqual(folder.name, expectedName) + XCTAssertEqual(folder.id, expectedFolder.id) + repository.verify() + } + + func test_기본폴더이름상태_폴더생성시_reservedName에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.expectCreate(callCount: 0) + + // When & Then + do { + _ = try sut.create(name: Policy.defaultFolderName) + XCTFail("FolderUseCaseError.reservedName 에러를 throw 해야 합니다.") + } catch FolderUseCaseError.reservedName { + // Success + } catch { + XCTFail("예상한 에러는 FolderUseCaseError.reservedName 이지만, 실제 받은 에러는 \(error) 입니다.") + } + + repository.verify() + } +} + +// MARK: - create(name:) 에러 케이스 + +extension FolderUseCaseTest { + func test_유효하지않은이름상태_폴더생성시_invalidName에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.expectCreate(callCount: 0) + let invalidNames = ["", " ", " \n ", " 새폴더", "새 폴더 ", " 새 폴더 "] + + // When & Then + for name in invalidNames { + do { + _ = try sut.create(name: name) + XCTFail("FolderUseCaseError.invalidName 에러를 throw 해야 합니다. (input: '\(name)')") + } catch { + guard case .invalidName = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.invalidName 이지만, 실제 받은 에러는 \(error) 입니다. (input: '\(name)')" + ) + return + } + } + } + + repository.verify() + } + + func test_너무긴이름상태_폴더생성시_invalidLengthName에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.expectCreate(callCount: 0) + let tooLongName = String(repeating: "a", count: 51) + + // When & Then + do { + _ = try sut.create(name: tooLongName) + XCTFail("FolderUseCaseError.invalidLengthName 에러를 throw 해야 합니다.") + } catch { + guard case .invalidLengthName = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.invalidLengthName 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } + + func test_중복된이름상태_폴더생성시_duplicateName에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.setCreateResult(.failure(.duplicateName)) + repository.expectCreate(callCount: 1) + + // When & Then + do { + _ = try sut.create(name: "Existing Folder") + XCTFail("FolderUseCaseError.duplicateName 에러를 throw 해야 합니다.") + } catch { + guard case .duplicateName = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.duplicateName 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } + + func test_리포지토리생성실패상태_폴더생성시_createFailed에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.setCreateResult(.failure(.createFailed)) + repository.expectCreate(callCount: 1) + + // When & Then + do { + _ = try sut.create(name: "New Folder") + XCTFail("FolderUseCaseError.createFailed 에러를 throw 해야 합니다.") + } catch { + guard case .createFailed = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.createFailed 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } + + func test_리포지토리알수없는에러상태_폴더생성시_unknown에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + struct DummyError: Error {} + let expectedError = DummyError() + repository.setCreateResult(.failure(.unknown(expectedError))) + repository.expectCreate(callCount: 1) + + // When & Then + do { + _ = try sut.create(name: "Unknown Test") + XCTFail("FolderUseCaseError.unknown 에러를 throw 해야 합니다.") + } catch { + guard case .unknown(let wrappedError) = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.unknown 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + XCTAssertTrue(wrappedError is DummyError) + } + + repository.verify() + } +} + +// MARK: - fetchAll() 성공 케이스 + +extension FolderUseCaseTest { + func test_정상상태_fetchAll호출시_삭제되지않은_모든폴더를반환한다() throws { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + let expectedFolders = [ + Folder.stub(name: "기본 폴더", kind: .default), + Folder.stub(name: "휴지통에 있는 폴더", deletedAt: Date()), + Folder.stub(name: "Folder 1", kind: .custom), + Folder.stub(name: "Folder 2", kind: .custom) + ] + repository.setFetchAllResult(.success(expectedFolders)) + repository.expectFetchAll(callCount: 1) + + // When + let folders = try sut.fetchAll() + + // Then + XCTAssertEqual(folders.count, 3) + XCTAssertEqual(folders[0].name, "기본 폴더") + XCTAssertEqual(folders[1].name, "Folder 1") + XCTAssertEqual(folders[2].name, "Folder 2") + repository.verify() + } + + func test_정상상태_fetchDeletableFolders호출시_기본과삭제된폴더를제외한_폴더목록만반환한다() throws { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + let expectedFolders = [ + Folder.stub(name: "기본 폴더", kind: .default), + Folder.stub(name: "휴지통에 있는 폴더", deletedAt: Date()), + Folder.stub(name: "Folder 1", kind: .custom), + Folder.stub(name: "Folder 2", kind: .custom) + ] + repository.setFetchAllResult(.success(expectedFolders)) + repository.expectFetchAll(callCount: 1) + + // When + let folders = try sut.fetchDeletableFolders() + + // Then + XCTAssertEqual(folders.count, 2) + XCTAssertEqual(folders[0].name, "Folder 1") + XCTAssertEqual(folders[1].name, "Folder 2") + repository.verify() + } +} + +// MARK: - fetchAll() 에러 케이스 + +extension FolderUseCaseTest { + func test_리포지토리조회실패상태_폴더조회시_fetchFailed에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.setFetchAllResult(.failure(.fetchFailed)) + repository.expectFetchAll(callCount: 1) + + // When & Then + do { + _ = try sut.fetchAll() + XCTFail("FolderUseCaseError.fetchFailed 에러를 throw 해야 합니다.") + } catch { + guard case .fetchFailed = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.fetchFailed 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } +} + +// MARK: - update(_:) 성공 케이스 + +extension FolderUseCaseTest { + func test_정상상태_폴더수정시_업데이트된폴더를반환한다() throws { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + let originalFolder = Folder.stub(name: "Old Name") + let updatedFolder = Folder.stub( + id: originalFolder.id, + name: "New Name", + createdAt: originalFolder.createdAt, + voiceNoteIDs: originalFolder.voiceNoteIDs, + kind: originalFolder.kind, + deletedAt: originalFolder.deletedAt + ) + + repository.setUpdateResult(.success(updatedFolder)) + repository.expectUpdate(folderID: updatedFolder.id, callCount: 1) + + // When + let result = try sut.update(updatedFolder) + + // Then + XCTAssertEqual(result.name, "New Name") + XCTAssertEqual(result.id, originalFolder.id) + repository.verify() + } +} + +// MARK: - update(_:) 에러 케이스 + +extension FolderUseCaseTest { + func test_너무긴이름상태_폴더수정시_invalidLengthName에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.expectUpdate(callCount: 0) + let tooLongName = String(repeating: "a", count: 51) + let folder = Folder(name: tooLongName) + + // When & Then + do { + _ = try sut.update(folder) + XCTFail("FolderUseCaseError.invalidLengthName 에러를 throw 해야 합니다.") + } catch { + guard case .invalidLengthName = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.invalidLengthName 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } + + func test_유효하지않은이름상태_폴더수정시_invalidName에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + repository.expectUpdate(callCount: 0) + let invalidNames = ["", " ", " \n ", " 새폴더", "새 폴더 ", " 새 폴더 "] + + // When & Then + for name in invalidNames { + let folder = Folder(name: name) + do { + _ = try sut.update(folder) + XCTFail("FolderUseCaseError.invalidName 에러를 throw 해야 합니다. (input: '\(name)')") + } catch { + guard case .invalidName = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.invalidName 이지만, 실제 받은 에러는 \(error) 입니다. (input: '\(name)')" + ) + return + } + } + } + + repository.verify() + } + + func test_폴더미존재상태_폴더수정시_notFound에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + let folder = Folder(name: "Any") + repository.setUpdateResult(.failure(.notFound)) + repository.expectUpdate(callCount: 1) + + // When & Then + do { + _ = try sut.update(folder) + XCTFail("FolderUseCaseError.notFound 에러를 throw 해야 합니다.") + } catch { + guard case .notFound = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.notFound 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } + + func test_중복된이름상태_폴더수정시_duplicateName에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + let folder = Folder(name: "New Name") + repository.setUpdateResult(.failure(.duplicateName)) + repository.expectUpdate(callCount: 1) + + // When & Then + do { + _ = try sut.update(folder) + XCTFail("FolderUseCaseError.duplicateName 에러를 throw 해야 합니다.") + } catch { + guard case .duplicateName = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.duplicateName 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } + + func test_리포지토리수정실패상태_폴더수정시_updateFailed에러를던진다() { + let repository = MockFolderRepository() + let sut = DefaultFolderUseCase(repository: repository) + + // Given + let folder = Folder(name: "Any") + repository.setUpdateResult(.failure(.updateFailed)) + repository.expectUpdate(callCount: 1) + + // When & Then + do { + _ = try sut.update(folder) + XCTFail("FolderUseCaseError.updateFailed 에러를 throw 해야 합니다.") + } catch { + guard case .updateFailed = error else { + XCTFail( + "예상한 에러는 FolderUseCaseError.updateFailed 이지만, 실제 받은 에러는 \(error) 입니다." + ) + return + } + } + + repository.verify() + } +} diff --git a/Domain/Tests/UseCases/VoiceNotes/VoiceNoteUseCaseTest.swift b/Domain/Tests/UseCases/VoiceNotes/VoiceNoteUseCaseTest.swift new file mode 100644 index 00000000..2ff0814c --- /dev/null +++ b/Domain/Tests/UseCases/VoiceNotes/VoiceNoteUseCaseTest.swift @@ -0,0 +1,97 @@ +@testable import Domain +import Core +import DomainTesting +import XCTest + +@MainActor +final class VoiceNoteUseCaseTest: XCTestCase { + private struct SUT { + let useCase: VoiceNoteUseCase + let repository: MockVoiceNoteRepository + let folderRepository: MockFolderRepository + let analysisService: MockVoiceNoteAnalysisService + } + + private func makeSUT() -> SUT { + let repository = MockVoiceNoteRepository() + let folderRepository = MockFolderRepository() + let analysisService = MockVoiceNoteAnalysisService() + let useCase = DefaultVoiceNoteUseCase( + repository: repository, + folderRepository: folderRepository, + analysisService: analysisService + ) + return SUT( + useCase: useCase, + repository: repository, + folderRepository: folderRepository, + analysisService: analysisService + ) + } +} + +// MARK: - Create + +extension VoiceNoteUseCaseTest { + func test_create_정상호출시_리포지토리를호출하고결과를반환한다() throws { + let sut = makeSUT() + let voiceRecord = VoiceRecord.stub() + let defaultFolder = Folder.stub(name: "기본 폴더", kind: .default) + let expectedNote = VoiceNote.stub(voiceRecord: voiceRecord) + + sut.folderRepository.setFetchByKindResult(.default, result: .success([defaultFolder])) + sut.repository.setCreateResult(.success(expectedNote)) + sut.repository.expectCreate(callCount: 1) + sut.analysisService.expectEnqueue(callCount: 1) + + let result = try sut.useCase.create(voiceRecord) + + XCTAssertEqual(result.id, expectedNote.id) + sut.repository.verify() + sut.analysisService.verify() + } +} + +// MARK: - Update + +extension VoiceNoteUseCaseTest { + func test_update_제목이비어있으면_invalidTitle에러를던진다() { + let sut = makeSUT() + let voiceNote = VoiceNote.stub(title: "") + + do { + _ = try sut.useCase.update(voiceNote) + XCTFail("에러가 발생해야 합니다.") + } catch { + guard case VoiceNoteUseCaseError.invalidTitle = error else { + return XCTFail("잘못된 에러 타입: \(error)") + } + } + } + + func test_update_정상호출시_리포지토리를호출하고결과를반환한다() throws { + let sut = makeSUT() + let voiceNote = VoiceNote.stub(title: "수정된 제목") + sut.repository.setUpdateResult(.success(voiceNote)) + sut.repository.expectUpdate(callCount: 1) + + let result = try sut.useCase.update(voiceNote) + + XCTAssertEqual(result.title, "수정된 제목") + sut.repository.verify() + } +} + +// MARK: - Regenerate + +extension VoiceNoteUseCaseTest { + func test_regenerateSummary_호출시_분석서비스의regenerate를호출한다() { + let sut = makeSUT() + let id = UUID() + sut.analysisService.expectRegenerate(callCount: 1) + + sut.useCase.regenerateSummary(id: id) + + sut.analysisService.verify() + } +} diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..7a118b49 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..747dc6de --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,338 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1233.0) + aws-sdk-core (3.244.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.218.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + benchmark (0.5.0) + bigdecimal (4.1.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.232.2) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + nkf (~> 0.2.0) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.98.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.26.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.61.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.19.3) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.19.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.5) + rake (13.3.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.4.1) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + fastlane + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1233.0) sha256=928d3486082db11659397eb4c957f41e33fac8848bf87eb42fd921bbb96213c2 + aws-sdk-core (3.244.0) sha256=3e458c078b0c5bdee95bc370c3a483374b3224cf730c1f9f0faf849a5d9a18ea + aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20 + aws-sdk-s3 (1.218.0) sha256=5672a5f32107f2adfa8ca1ff33188fb534a6fd7560c52e8817458698c7c9988c + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507 + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.0) sha256=6dc07767aa3dc456ccd48e7ae70a07b474e9afd7c5bc576f80bd6da5c8dd6cae + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0 + faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 + faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 + faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 + faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 + faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 + faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 + faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 + faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa + faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 + fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112 + fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + google-apis-androidpublisher_v3 (0.98.0) sha256=094fb952419c1131c16c4dfa66e0c96e6a2fa33adbe266f614b84b22cbc8c5cb + google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3 + google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78 + google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8 + google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf + google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999 + google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1 + google-cloud-storage (1.59.0) sha256=b8c9a5661d775d65ccb279bb1d6be07fd8152576eb0146c2026bd023c4b186b9 + googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7 + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace + retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75 + terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3 + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + +BUNDLED WITH + 4.0.9 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..12f9c2ed --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: format test generate + +format: + swiftformat --config .swiftformat . + +generate: + tuist generate + +test: format + tuist test diff --git a/Presentation/Project.swift b/Presentation/Project.swift new file mode 100644 index 00000000..effaa5bc --- /dev/null +++ b/Presentation/Project.swift @@ -0,0 +1,80 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +private let presentationScheme = Scheme.scheme( + name: "Presentation", + shared: true, + buildAction: .buildAction( + targets: [.target("Presentation")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("PresentationTests"), parallelization: .disabled) + ]) +) + +private let presentationTestsScheme = Scheme.scheme( + name: "PresentationTests", + shared: true, + buildAction: .buildAction( + targets: [.target("PresentationTests")], + findImplicitDependencies: true + ), + testAction: .targets([ + .testableTarget(target: .target("PresentationTests"), parallelization: .disabled) + ]) +) + +private let presentationTarget = Target.target( + name: "Presentation", + destinations: .iOS, + product: .framework, + bundleId: "\(bundleId).Presentation", + deploymentTargets: deploymentTargets, + infoPlist: .extendingDefault( + with: [ + "UIAppFonts": .array([ + "Pretendard-Bold.otf", + "Pretendard-Medium.otf", + "Pretendard-Regular.otf" + ]) + ] + ), + sources: ["Sources/**/*.swift"], + resources: ["Resources/**"], + dependencies: [ + .project(target: "Core", path: "../Core"), + .project(target: "Domain", path: "../Domain") + ] +) + +private let presentationTestsTarget = Target.target( + name: "PresentationTests", + destinations: .iOS, + product: .unitTests, + bundleId: "\(bundleId).PresentationTests", + deploymentTargets: deploymentTargets, + infoPlist: .default, + sources: ["Tests/**/*.swift"], + dependencies: [ + .target(name: "Presentation"), + .project(target: "DomainTesting", path: "../Domain") + ] +) + +let project = Project( + name: "Presentation", + options: .options( + defaultKnownRegions: ["ko", "en"], + developmentRegion: "ko" + ), + settings: settings, + targets: [ + presentationTarget, + presentationTestsTarget + ], + schemes: [ + presentationScheme, + presentationTestsScheme + ] +) diff --git a/ChaGok/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/Contents.json similarity index 51% rename from ChaGok/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json rename to Presentation/Resources/Assets.xcassets/Contents.json index eb878970..73c00596 100644 --- a/ChaGok/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Presentation/Resources/Assets.xcassets/Contents.json @@ -1,9 +1,4 @@ { - "colors" : [ - { - "idiom" : "universal" - } - ], "info" : { "author" : "xcode", "version" : 1 diff --git a/Presentation/Resources/Assets.xcassets/Danger.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/Danger.colorset/Contents.json new file mode 100644 index 00000000..116e94dc --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Danger.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x58", + "green" : "0x37", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray0.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray0.colorset/Contents.json new file mode 100644 index 00000000..658aacbc --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray0.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray100.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray100.colorset/Contents.json new file mode 100644 index 00000000..63b99526 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray100.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x21", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x21", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray1000.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray1000.colorset/Contents.json new file mode 100644 index 00000000..2536dc2d --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray1000.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray200.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray200.colorset/Contents.json new file mode 100644 index 00000000..d5176fb2 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray200.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x32", + "green" : "0x32", + "red" : "0x32" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x32", + "green" : "0x32", + "red" : "0x32" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray300.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray300.colorset/Contents.json new file mode 100644 index 00000000..a3505c08 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray300.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x43", + "green" : "0x43", + "red" : "0x43" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x43", + "green" : "0x43", + "red" : "0x43" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray350.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray350.colorset/Contents.json new file mode 100644 index 00000000..13fe8447 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray350.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x4B", + "green" : "0x4B", + "red" : "0x4B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x4B", + "green" : "0x4B", + "red" : "0x4B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray400.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray400.colorset/Contents.json new file mode 100644 index 00000000..3db5e585 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray400.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5A", + "green" : "0x5A", + "red" : "0x5A" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5A", + "green" : "0x5A", + "red" : "0x5A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray50.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray50.colorset/Contents.json new file mode 100644 index 00000000..14e306a6 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray50.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.071", + "green" : "0.071", + "red" : "0.071" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x12", + "green" : "0x12", + "red" : "0x12" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray500.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray500.colorset/Contents.json new file mode 100644 index 00000000..f8c0cd7d --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray500.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x67", + "green" : "0x67", + "red" : "0x67" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x67", + "green" : "0x67", + "red" : "0x67" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray600.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray600.colorset/Contents.json new file mode 100644 index 00000000..b3a63e5b --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray600.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7B", + "green" : "0x7B", + "red" : "0x7B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7B", + "green" : "0x7B", + "red" : "0x7B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray700.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray700.colorset/Contents.json new file mode 100644 index 00000000..d53025f7 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray700.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8F", + "green" : "0x8F", + "red" : "0x8F" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8F", + "green" : "0x8F", + "red" : "0x8F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray750.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray750.colorset/Contents.json new file mode 100644 index 00000000..a98f08f7 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray750.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA3", + "green" : "0xA3", + "red" : "0xA3" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA3", + "green" : "0xA3", + "red" : "0xA3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray775.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray775.colorset/Contents.json new file mode 100644 index 00000000..446ea704 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray775.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB3", + "green" : "0xB3", + "red" : "0xB3" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB3", + "green" : "0xB3", + "red" : "0xB3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray800.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray800.colorset/Contents.json new file mode 100644 index 00000000..a04d095a --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray800.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB8", + "green" : "0xB8", + "red" : "0xB8" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB8", + "green" : "0xB8", + "red" : "0xB8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray850.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray850.colorset/Contents.json new file mode 100644 index 00000000..f62bca69 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray850.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCD", + "green" : "0xCD", + "red" : "0xCD" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCD", + "green" : "0xCD", + "red" : "0xCD" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray900.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray900.colorset/Contents.json new file mode 100644 index 00000000..43814aa3 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray900.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE3", + "green" : "0xE3", + "red" : "0xE3" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE3", + "green" : "0xE3", + "red" : "0xE3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/GrayColors/Gray950.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/GrayColors/Gray950.colorset/Contents.json new file mode 100644 index 00000000..95954f7f --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/GrayColors/Gray950.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF9", + "green" : "0xF9", + "red" : "0xF9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF9", + "green" : "0xF9", + "red" : "0xF9" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/chevronDown.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/chevronDown.imageset/Contents.json new file mode 100644 index 00000000..675e3249 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/chevronDown.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chevronDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/chevronDown.imageset/chevronDown.svg b/Presentation/Resources/Assets.xcassets/Icons/chevronDown.imageset/chevronDown.svg new file mode 100644 index 00000000..15893161 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/chevronDown.imageset/chevronDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/chevronLeft.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/chevronLeft.imageset/Contents.json new file mode 100644 index 00000000..7a4a38ea --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/chevronLeft.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chevronLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/chevronLeft.imageset/chevronLeft.svg b/Presentation/Resources/Assets.xcassets/Icons/chevronLeft.imageset/chevronLeft.svg new file mode 100644 index 00000000..54ba1399 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/chevronLeft.imageset/chevronLeft.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/chevronUp.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/chevronUp.imageset/Contents.json new file mode 100644 index 00000000..9982c8ca --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/chevronUp.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chevronUp.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/chevronUp.imageset/chevronUp.svg b/Presentation/Resources/Assets.xcassets/Icons/chevronUp.imageset/chevronUp.svg new file mode 100644 index 00000000..621093da --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/chevronUp.imageset/chevronUp.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/cloudOff.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/cloudOff.imageset/Contents.json new file mode 100644 index 00000000..397bf9da --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/cloudOff.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "cloudOff.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/cloudOff.imageset/cloudOff.pdf b/Presentation/Resources/Assets.xcassets/Icons/cloudOff.imageset/cloudOff.pdf new file mode 100644 index 00000000..eb41c574 Binary files /dev/null and b/Presentation/Resources/Assets.xcassets/Icons/cloudOff.imageset/cloudOff.pdf differ diff --git a/Presentation/Resources/Assets.xcassets/Icons/cornerUpLeft.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/cornerUpLeft.imageset/Contents.json new file mode 100644 index 00000000..6ab8cfee --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/cornerUpLeft.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "cornerUpLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/cornerUpLeft.imageset/cornerUpLeft.svg b/Presentation/Resources/Assets.xcassets/Icons/cornerUpLeft.imageset/cornerUpLeft.svg new file mode 100644 index 00000000..3747973e --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/cornerUpLeft.imageset/cornerUpLeft.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/entertainmentRecording.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/entertainmentRecording.imageset/Contents.json new file mode 100644 index 00000000..bbada6ac --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/entertainmentRecording.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "entertainmentRecording.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/entertainmentRecording.imageset/entertainmentRecording.pdf b/Presentation/Resources/Assets.xcassets/Icons/entertainmentRecording.imageset/entertainmentRecording.pdf new file mode 100644 index 00000000..c981b8c0 Binary files /dev/null and b/Presentation/Resources/Assets.xcassets/Icons/entertainmentRecording.imageset/entertainmentRecording.pdf differ diff --git a/Presentation/Resources/Assets.xcassets/Icons/folder.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/folder.imageset/Contents.json new file mode 100644 index 00000000..3c88b0c2 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/folder.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "folder.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/folder.imageset/folder.svg b/Presentation/Resources/Assets.xcassets/Icons/folder.imageset/folder.svg new file mode 100644 index 00000000..0f305e43 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/folder.imageset/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/forward.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/forward.imageset/Contents.json new file mode 100644 index 00000000..ae7903c1 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/forward.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "forward.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/forward.imageset/forward.svg b/Presentation/Resources/Assets.xcassets/Icons/forward.imageset/forward.svg new file mode 100644 index 00000000..8b2a3a70 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/forward.imageset/forward.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/interfaceLockShield.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/interfaceLockShield.imageset/Contents.json new file mode 100644 index 00000000..27c01c06 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/interfaceLockShield.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "interfaceLockShield.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/interfaceLockShield.imageset/interfaceLockShield.pdf b/Presentation/Resources/Assets.xcassets/Icons/interfaceLockShield.imageset/interfaceLockShield.pdf new file mode 100644 index 00000000..ad2c4a54 Binary files /dev/null and b/Presentation/Resources/Assets.xcassets/Icons/interfaceLockShield.imageset/interfaceLockShield.pdf differ diff --git a/Presentation/Resources/Assets.xcassets/Icons/moreVertical.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/moreVertical.imageset/Contents.json new file mode 100644 index 00000000..54b051f6 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/moreVertical.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "moreVertical.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/moreVertical.imageset/moreVertical.svg b/Presentation/Resources/Assets.xcassets/Icons/moreVertical.imageset/moreVertical.svg new file mode 100644 index 00000000..c63cc66a --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/moreVertical.imageset/moreVertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/play.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/play.imageset/Contents.json new file mode 100644 index 00000000..bfeb7393 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/play.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "play.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/play.imageset/play.svg b/Presentation/Resources/Assets.xcassets/Icons/play.imageset/play.svg new file mode 100644 index 00000000..36ffa3ca --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/play.imageset/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/rewind.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/rewind.imageset/Contents.json new file mode 100644 index 00000000..e56183cb --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/rewind.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "rewind.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/rewind.imageset/rewind.svg b/Presentation/Resources/Assets.xcassets/Icons/rewind.imageset/rewind.svg new file mode 100644 index 00000000..118b263f --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/rewind.imageset/rewind.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Presentation/Resources/Assets.xcassets/Icons/search.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Icons/search.imageset/Contents.json new file mode 100644 index 00000000..c83ec35f --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/search.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "search.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Icons/search.imageset/search.svg b/Presentation/Resources/Assets.xcassets/Icons/search.imageset/search.svg new file mode 100644 index 00000000..77066df8 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Icons/search.imageset/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/Resources/Assets.xcassets/Images/Contents.json b/Presentation/Resources/Assets.xcassets/Images/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Images/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/Images/onboarding01.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Images/onboarding01.imageset/Contents.json new file mode 100644 index 00000000..5f97ec47 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Images/onboarding01.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding01.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Images/onboarding01.imageset/onboarding01.pdf b/Presentation/Resources/Assets.xcassets/Images/onboarding01.imageset/onboarding01.pdf new file mode 100644 index 00000000..1fbd0f3e Binary files /dev/null and b/Presentation/Resources/Assets.xcassets/Images/onboarding01.imageset/onboarding01.pdf differ diff --git a/Presentation/Resources/Assets.xcassets/Images/onboarding02.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Images/onboarding02.imageset/Contents.json new file mode 100644 index 00000000..eab93940 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Images/onboarding02.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding02.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Images/onboarding02.imageset/onboarding02.pdf b/Presentation/Resources/Assets.xcassets/Images/onboarding02.imageset/onboarding02.pdf new file mode 100644 index 00000000..9f43f4e1 Binary files /dev/null and b/Presentation/Resources/Assets.xcassets/Images/onboarding02.imageset/onboarding02.pdf differ diff --git a/Presentation/Resources/Assets.xcassets/Images/onboarding03.imageset/Contents.json b/Presentation/Resources/Assets.xcassets/Images/onboarding03.imageset/Contents.json new file mode 100644 index 00000000..4e9ed2ac --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Images/onboarding03.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding03.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Presentation/Resources/Assets.xcassets/Images/onboarding03.imageset/onboarding03.pdf b/Presentation/Resources/Assets.xcassets/Images/onboarding03.imageset/onboarding03.pdf new file mode 100644 index 00000000..6007481b Binary files /dev/null and b/Presentation/Resources/Assets.xcassets/Images/onboarding03.imageset/onboarding03.pdf differ diff --git a/Presentation/Resources/Assets.xcassets/Info.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/Info.colorset/Contents.json new file mode 100644 index 00000000..86f26b45 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Info.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0x82", + "red" : "0x3B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0x82", + "red" : "0x3B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point100.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point100.colorset/Contents.json new file mode 100644 index 00000000..ab7fa501 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point100.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x26", + "green" : "0x00", + "red" : "0x09" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x26", + "green" : "0x00", + "red" : "0x09" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point1000.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point1000.colorset/Contents.json new file mode 100644 index 00000000..3ed2d0cc --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point1000.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xDA", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xDA", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point150.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point150.colorset/Contents.json new file mode 100644 index 00000000..024e2cb5 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point150.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x31", + "green" : "0x12", + "red" : "0x1E" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x31", + "green" : "0x12", + "red" : "0x1E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point200.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point200.colorset/Contents.json new file mode 100644 index 00000000..19e10dc8 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point200.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3F", + "green" : "0x00", + "red" : "0x13" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3F", + "green" : "0x00", + "red" : "0x13" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point300.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point300.colorset/Contents.json new file mode 100644 index 00000000..7bfeace5 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point300.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x62", + "green" : "0x00", + "red" : "0x25" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x62", + "green" : "0x00", + "red" : "0x25" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point400.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point400.colorset/Contents.json new file mode 100644 index 00000000..169f1c7e --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point400.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x85", + "green" : "0x00", + "red" : "0x3C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x85", + "green" : "0x00", + "red" : "0x3C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point500.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point500.colorset/Contents.json new file mode 100644 index 00000000..ffb109f7 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point500.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAA", + "green" : "0x24", + "red" : "0x57" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAA", + "green" : "0x24", + "red" : "0x57" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point600.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point600.colorset/Contents.json new file mode 100644 index 00000000..f9b8ffc1 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point600.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCF", + "green" : "0x4A", + "red" : "0x75" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCF", + "green" : "0x4A", + "red" : "0x75" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point700.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point700.colorset/Contents.json new file mode 100644 index 00000000..12356678 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point700.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF5", + "green" : "0x6E", + "red" : "0x95" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF5", + "green" : "0x6E", + "red" : "0x95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point800.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point800.colorset/Contents.json new file mode 100644 index 00000000..97bf1478 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point800.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x91", + "red" : "0xB6" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x91", + "red" : "0xB6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/PointColors/Point900.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/PointColors/Point900.colorset/Contents.json new file mode 100644 index 00000000..aec762b4 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/PointColors/Point900.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xB5", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xB5", + "red" : "0xD9" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/Success.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/Success.colorset/Contents.json new file mode 100644 index 00000000..493ca9a4 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Success.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x59", + "green" : "0xC7", + "red" : "0x34" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x59", + "green" : "0xC7", + "red" : "0x34" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/Warning.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/Warning.colorset/Contents.json new file mode 100644 index 00000000..9df70dea --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Warning.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x07", + "green" : "0xC1", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x07", + "green" : "0xC1", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Assets.xcassets/Warning2.colorset/Contents.json b/Presentation/Resources/Assets.xcassets/Warning2.colorset/Contents.json new file mode 100644 index 00000000..6c7d0052 --- /dev/null +++ b/Presentation/Resources/Assets.xcassets/Warning2.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x95", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x95", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/Resources/Font/Pretendard-Bold.otf b/Presentation/Resources/Font/Pretendard-Bold.otf new file mode 100644 index 00000000..8e5e30a2 Binary files /dev/null and b/Presentation/Resources/Font/Pretendard-Bold.otf differ diff --git a/Presentation/Resources/Font/Pretendard-Medium.otf b/Presentation/Resources/Font/Pretendard-Medium.otf new file mode 100644 index 00000000..05750698 Binary files /dev/null and b/Presentation/Resources/Font/Pretendard-Medium.otf differ diff --git a/Presentation/Resources/Font/Pretendard-Regular.otf b/Presentation/Resources/Font/Pretendard-Regular.otf new file mode 100644 index 00000000..08bf4cfc Binary files /dev/null and b/Presentation/Resources/Font/Pretendard-Regular.otf differ diff --git a/Presentation/Sources/Component/Common/AlertView.swift b/Presentation/Sources/Component/Common/AlertView.swift new file mode 100644 index 00000000..bd066195 --- /dev/null +++ b/Presentation/Sources/Component/Common/AlertView.swift @@ -0,0 +1,182 @@ +import UIKit + +final class AlertView: UIView { + var closeButton: GlassButton + + var primaryButton: GlassButton + + private let title: String + private let subTitle: String + private var widthConstraint: NSLayoutConstraint? + + private let topContent: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = Constant.alertTopContentSpacing + view.distribution = .fill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let bottomContent: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.distribution = .fill + view.spacing = Constant.alertBottomContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var header: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.setTypography(text: title, style: .title2) + t.textAlignment = .center + t.textColor = UIColor.gray950 + t.numberOfLines = 0 + return t + }() + + private lazy var body: UILabel = { + let d = UILabel() + d.translatesAutoresizingMaskIntoConstraints = false + d.setTypography(text: subTitle, style: .body1) + d.textAlignment = .center + d.textColor = UIColor.gray950 + d.numberOfLines = 0 + return d + }() + + init( + title: String, + subTitle: String, + closeButton: GlassButton, + primaryButton: GlassButton, + tintColor: UIColor = .point200.withAlphaComponent(0.2), + frame: CGRect = .zero + ) { + self.title = title + self.subTitle = subTitle + self.closeButton = closeButton + self.primaryButton = primaryButton + super.init(frame: frame) + applyGlassEffect(tintColor: tintColor) + setup() + setupButton() + childSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } +} + +// MARK: - LifeCycle + +extension AlertView { + override func didMoveToSuperview() { + super.didMoveToSuperview() + guard let superview else { + widthConstraint?.isActive = false + widthConstraint = nil + return + } + guard widthConstraint == nil else { return } + let width = widthAnchor.constraint(equalTo: superview.widthAnchor, multiplier: Constant.alertMultiplierWidth) + width.isActive = true + widthConstraint = width + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = Constant.cornerRadius + + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = Constant.shadowOpacity + layer.shadowOffset = CGSize(width: Constant.shadowOffsetWidth, height: Constant.shadowOffsetHeight) + layer.shadowRadius = Constant.cornerRadius + layer.shadowPath = + UIBezierPath( + roundedRect: bounds, + cornerRadius: Constant.cornerRadius + ).cgPath + } +} + +// MARK: - setUp + +extension AlertView { + /// AlertView의 전체 배경색, 테두리(border), 모서리 등 가장 기초적인 View 스타일을 설정합니다. + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + layer.borderWidth = Constant.borderWidth + layer.borderColor = UIColor.gray600.cgColor + } + + /// AlertView에 맞게 외부 설정 값 상관 없이 내부에서 일관된 디자인을 처리합니다. + private func setupButton() { + // close + closeButton.setShadow(false) + closeButton.setCapsuleCornerRadius() + // primary + primaryButton.setShadow(false) + primaryButton.setCapsuleCornerRadius() + } + + /// AlertView 내부의 컴포넌트들(제목, 부제목, 버튼 등)을 StackView에 배치하고 + /// 오토레이아웃 제약 조건을 설정합니다. + private func childSetup() { + topContent.addArrangedSubview(header) + topContent.addArrangedSubview(body) + bottomContent.addArrangedSubview(closeButton) + bottomContent.addArrangedSubview(primaryButton) + addSubview(topContent) + addSubview(bottomContent) + + NSLayoutConstraint.activate([ + topContent.topAnchor.constraint(equalTo: topAnchor, constant: Constant.alertTopAndBottomValueForTopContent), + topContent.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: Constant.alertLeftAndRightValueForTopContent + ), + topContent.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -Constant.alertLeftAndRightValueForTopContent + ), + topContent.bottomAnchor.constraint( + equalTo: bottomContent.topAnchor, + constant: -Constant.alertTopAndBottomContentSpacing + ), + bottomContent.heightAnchor.constraint(equalToConstant: Constant.alertBottomContentHeight), + bottomContent.bottomAnchor.constraint( + equalTo: bottomAnchor, + constant: -Constant.alertTopAndBottomValueForTopContent + ), + bottomContent.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: Constant.alertLeftAndRightValueForBottomContent + ), + bottomContent.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -Constant.alertLeftAndRightValueForBottomContent + ) + ]) + } +} + +// MARK: - Configure + +extension AlertView { + func configure( + title: String, + subtitle: String, + closeButton: GlassButton, + primaryButton: GlassButton + ) { + header.setTypography(text: title, style: .title2) + body.setTypography(text: subTitle, style: .body1) + self.closeButton = closeButton + self.primaryButton = primaryButton + } +} diff --git a/Presentation/Sources/Component/Common/ChagokSearchBar.swift b/Presentation/Sources/Component/Common/ChagokSearchBar.swift new file mode 100644 index 00000000..36550710 --- /dev/null +++ b/Presentation/Sources/Component/Common/ChagokSearchBar.swift @@ -0,0 +1,137 @@ +import UIKit + +final class ChagokSearchBar: UIView { + // MARK: - Component + + private let searchContainer: UIVisualEffectView = { + let effect = UIGlassEffect(style: .clear) + effect.tintColor = .point100.withAlphaComponent(0.2) + let view = UIVisualEffectView() + view.cornerConfiguration = .corners( + radius: .fixed(Constant.cornerRadius) + ) + UIView.animate { + effect.isInteractive = true + view.effect = effect + } + view.setGradientBorder( + colors: [ + UIColor.gray900, + UIColor.gray300 + ], + width: 1, + cornerRadius: Constant.cornerRadius, + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: 1, y: 1) + ) + return view + }() + + private let iconView: UIImageView = { + let imageView = UIImageView(image: .search) + imageView.tintColor = .gray850 + imageView.contentMode = .scaleAspectFit + return imageView + }() + + let textField: TypographyTextField = { + let field = TypographyTextField(typography: .body1) + field.textColor = .white + field.tintColor = .white + field.returnKeyType = .search + field.clearButtonMode = .never + field.autocorrectionType = .no + field.autocapitalizationType = .none + field.spellCheckingType = .no + var placeholderAttrs = Typography.body1.textAttributes + placeholderAttrs[.foregroundColor] = UIColor.gray950 + field.attributedPlaceholder = NSAttributedString(string: "검색", attributes: placeholderAttrs) + return field + }() + + let closeButton: GlassButton = { + let btn = GlassButton() + btn.configure( + "", + typography: .body1, + border: .init( + color: .gradient([ + UIColor.gray300, + UIColor.gray900 + ]), + width: 1 + ), + image: .init(imageName: "xmark", type: .system, configuration: .init(pointSize: 12)), + backgroundColor: .color(.point100.withAlphaComponent(0.2)), + foregroundColor: .white + ) + return btn + }() + + // MARK: - Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + nil + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.layoutFittingExpandedSize.width, height: 44) + } + + // MARK: - Setup + + private func setupUI() { + addSubview(searchContainer) + addSubview(closeButton) + searchContainer.contentView.addSubview(iconView) + searchContainer.contentView.addSubview(textField) + + for item in [searchContainer, closeButton, iconView, textField] { + item.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 44), + + searchContainer.topAnchor.constraint(equalTo: topAnchor), + searchContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + searchContainer.bottomAnchor.constraint(equalTo: bottomAnchor), + searchContainer.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -12), + + closeButton.topAnchor.constraint(equalTo: topAnchor), + closeButton.trailingAnchor.constraint(equalTo: trailingAnchor), + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), + + iconView.leadingAnchor.constraint(equalTo: searchContainer.contentView.leadingAnchor, constant: 16), + iconView.centerYAnchor.constraint(equalTo: searchContainer.contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 20), + iconView.heightAnchor.constraint(equalToConstant: 20), + + textField.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), + textField.trailingAnchor.constraint(equalTo: searchContainer.contentView.trailingAnchor, constant: -16), + textField.topAnchor.constraint(equalTo: searchContainer.contentView.topAnchor), + textField.bottomAnchor.constraint(equalTo: searchContainer.contentView.bottomAnchor) + ]) + } +} + +#Preview { + let vc = UIViewController() + vc.view.backgroundColor = .gray50 + let bar = ChagokSearchBar() + bar.translatesAutoresizingMaskIntoConstraints = false + vc.view.addSubview(bar) + NSLayoutConstraint.activate([ + bar.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor, constant: 16), + bar.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor, constant: -16), + bar.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor), + bar.heightAnchor.constraint(equalToConstant: 46) + ]) + return vc +} diff --git a/Presentation/Sources/Component/Common/DownloadModelCard.swift b/Presentation/Sources/Component/Common/DownloadModelCard.swift new file mode 100644 index 00000000..019212ce --- /dev/null +++ b/Presentation/Sources/Component/Common/DownloadModelCard.swift @@ -0,0 +1,171 @@ +import Domain +import SwiftUI +import UIKit + +final class DownloadModelCard: UIStackView { + let modelName: String + let symbolName: String + let style: ProgressStyle + var storage: OnDeviceStatus.StorageState + var errorMessage: String? + + // MARK: - Initialize + + init( + symbolName: String, + modelName: String, + style: ProgressStyle, + storage: OnDeviceStatus.StorageState, + errorMessage: String? = nil, + frame: CGRect = .zero + ) { + self.symbolName = symbolName + self.modelName = modelName + self.style = style + self.storage = storage + self.errorMessage = errorMessage + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Component + + private lazy var modelLabel: UIStackView = createLabel(modelName, symbolName: symbolName) + + private lazy var immutableProgressView = ImmutableProgressView() + private lazy var defaultProgressView = DefaultProgressView() + + private let downloadMessageLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: "다운로드 상태 표기", style: .body2) + return label + }() + + // MARK: - LifeCycle + + override func updateProperties() { + super.updateProperties() + updateStatus() + } + + // MARK: - Setup + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + axis = .vertical + spacing = 8 + applyGlassEffect(tintColor: .point200.withAlphaComponent(0.2)) + layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + isLayoutMarginsRelativeArrangement = true + + addArrangedSubview(modelLabel) + switch style { + case .default: + addArrangedSubview(defaultProgressView) + defaultProgressView.heightAnchor.constraint(equalToConstant: 8).isActive = true + case .immutable: + addArrangedSubview(immutableProgressView) + immutableProgressView.heightAnchor.constraint(equalToConstant: 8).isActive = true + } + addArrangedSubview(downloadMessageLabel) + } + + /// 프로그래스의 스타일을 정의합니다. + enum ProgressStyle: Equatable { + case `default` // 기본 스타일 + case immutable // 불변 프로그래스 바 + } +} + +// MARK: - Private + +extension DownloadModelCard { + /// model의 이름과 이미지를 표기하는 View입니다. + func createLabel(_ modelName: String, symbolName: String) -> UIStackView { + let container = UIStackView() + let imageView = UIImageView() + let nameLabel = UILabel() + + for item in [container, imageView, nameLabel] { + item.translatesAutoresizingMaskIntoConstraints = false + } + + // nameLabel + nameLabel.setTypography(text: modelName, style: .body2) + nameLabel.textColor = UIColor.gray950 + // imageView + let config: UIImage.SymbolConfiguration = .init(pointSize: 20, weight: .medium) + imageView.image = UIImage(systemName: symbolName, withConfiguration: config) + imageView.contentMode = .scaleAspectFit + imageView.tintColor = UIColor.gray950 + // container (return) + container.axis = .horizontal + container.spacing = 8 + // spacer + let spacer = UIView() + for item in [imageView, nameLabel, spacer] { + container.addArrangedSubview(item) + } + + return container + } +} + +// MARK: - Update + +extension DownloadModelCard { + func updateStatus(_ storage: OnDeviceStatus.StorageState, errorMessage: String?) { + self.storage = storage + self.errorMessage = errorMessage + setNeedsUpdateProperties() + } + + private func updateStatus() { + switch storage { + case .notDownloaded: + switch style { + case .default: + defaultProgressView.isHidden = true + case .immutable: + immutableProgressView.isHidden = true + } + downloadMessageLabel.isHidden = true + case .downloading(let progress): + switch style { + case .default: + defaultProgressView.isHidden = false + defaultProgressView.setProgress(Float(progress), animated: true) + case .immutable: + immutableProgressView.isHidden = false + } + downloadMessageLabel.isHidden = false + downloadMessageLabel.setTypography(text: "다운로드 중...", style: .body2) + downloadMessageLabel.textColor = .gray950 + case .downloaded: + switch style { + case .default: + defaultProgressView.isHidden = true + case .immutable: + immutableProgressView.isHidden = true + } + downloadMessageLabel.isHidden = true + case .failed: + switch style { + case .default: + defaultProgressView.isHidden = true + case .immutable: + immutableProgressView.isHidden = true + } + downloadMessageLabel.isHidden = false + let msg = (errorMessage == nil || errorMessage?.isEmpty == true) ? "다운로드에 실패했습니다" : errorMessage + downloadMessageLabel.setTypography(text: msg, style: .body2) + downloadMessageLabel.textColor = .danger + } + } +} diff --git a/Presentation/Sources/Component/Common/GlassButton.swift b/Presentation/Sources/Component/Common/GlassButton.swift new file mode 100644 index 00000000..c7facb31 --- /dev/null +++ b/Presentation/Sources/Component/Common/GlassButton.swift @@ -0,0 +1,398 @@ +import UIKit + +/// 투명한 글래스 효과(Glassmorphism)가 적용된 커스텀 버튼 클래스입니다. +/// UIButton.Configuration의 prominentClearGlass 스타일을 기반으로 하며, 커스텀 테두리 및 배경색 설정을 지원합니다. +final class GlassButton: UIButton { + var isShadow: Bool = true + var cornerRadius: CGFloat = Constant.cornerRadius + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupStyle() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Lifecycle + + override var isHighlighted: Bool { + didSet { + updateStyle() + } + } + + override var isEnabled: Bool { + didSet { + updateStyle() + } + } + + private func updateStyle() {} + + override func layoutSubviews() { + super.layoutSubviews() + guard isShadow else { return } + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = Constant.shadowOpacity + layer.shadowOffset = CGSize( + width: Constant.shadowOffsetWidth, height: Constant.shadowOffsetHeight + ) + layer.shadowRadius = cornerRadius + + layer.shadowPath = + UIBezierPath( + roundedRect: bounds, + cornerRadius: cornerRadius + ).cgPath + } + + override func updateConfiguration() { + super.updateConfiguration() + configuration?.background.cornerRadius = cornerRadius + + if let unifiedView = configuration?.background.customView as? UnifiedGradientView { + unifiedView.updateCornerRadius(cornerRadius) + } + } +} + +// MARK: - 내부 Helper 함수 + +extension GlassButton { + /// 버튼 초기 생성 시 호출되어 기본적인 생성자 함수 + private func setupStyle() { + translatesAutoresizingMaskIntoConstraints = false + clipsToBounds = false + } + + /// GlassButton의 전반적인 디자인(텍스트, 폰트, 테두리, 배경색, 이미지 등)을 세부적으로 구성합니다. + /// 단일 색상(Solid Color)뿐만 아니라 배열 형태의 그라데이션(Gradient) 색상 적용도 투명 효과와 함께 지원합니다. + /// + /// - Parameters: + /// - title: 버튼 내부에 표시될 텍스트 문자열입니다. 타이틀이 필요 없을 경우 nil을 전달합니다. + /// - typography: 애플리케이션 공통 폰트 지정 열거형(`Typography`)으로 폰트 스타일을 적용합니다. + /// - border: 필요에 따라 테두리를 지정하는 `Border` 구조체를 전달합니다. 단색 또는 그라데이션(`GradientSet`), 두께를 설정할 수 있습니다. + /// - image: 버튼 내에 들어갈 아이콘 이미지(`ImageAsset`)를 지정합니다. 리소스 형식과 SFSymbol 형식을 모두 지정 가능합니다. + /// - backgroundColor: 버튼의 배경색을 결정하는 `GradientSet` 열거형입니다. 단색 또는 여러 색상 배열의 그라데이션을 사용할 수 있습니다. (기본값: + /// `.color(.point600)`) + /// - foregroundColor: 버튼 텍스트 및 이미지의 기본 색상입니다. (기본값: `.white`) + func configure( + type: UIButton.Configuration = .prominentGlass(), + _ title: String?, + typography: Typography, + border: Border? = nil, + image: ImageAsset? = nil, + backgroundColor: GradientSet = .color(.point600), + foregroundColor: UIColor = .white + ) { + var config: UIButton.Configuration = type + + config.title = title + config.baseForegroundColor = foregroundColor + config.background.cornerRadius = cornerRadius + config.cornerStyle = .fixed + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = typography.font + return outgoing + } + + if let image { + switch image.type { + case .resource: + config.image = UIImage(named: image.imageName) + case .system: + config.image = UIImage(systemName: image.imageName, withConfiguration: image.configuration) + } + } + + var needsCustomView = false + var bgColors: [UIColor]? = nil + var bgColor: UIColor? = nil + var borderColors: [UIColor]? = nil + + switch backgroundColor { + case .color(let color): + bgColor = color + config.baseBackgroundColor = color + case .gradient(let colors): + bgColors = colors + config.baseBackgroundColor = .clear + needsCustomView = true + } + + if let border { + switch border.color { + case .color(let color): + config.background.strokeColor = color + config.background.strokeWidth = border.width + case .gradient(let colors): + borderColors = colors + config.background.strokeWidth = 0 + needsCustomView = true + } + } + + if needsCustomView { + let unifiedView = UnifiedGradientView( + bgColor: bgColors == nil ? bgColor : nil, + bgColors: bgColors, + borderColors: borderColors, + borderWidth: border?.width ?? 0, + cornerRadius: cornerRadius + ) + config.background.customView = unifiedView + + if bgColors == nil { + config.baseBackgroundColor = .clear + } + } + + configuration = config + } + + /// 그림자 적용 여부를 판단합니다. 그림자가 필요 없는 경우 호출하여 비활성화합니다. + func setShadow(_ val: Bool) { + isShadow = val + } + + /// Policy에 정의된 Capsule CornerRadius 값을 버튼 모서리에 전역으로 지정합니다. + /// 알약처럼 둥근 모서리 디자인이 요구될 경우 호출하세요. + func setCapsuleCornerRadius() { + cornerRadius = Constant.capsuleCornerRadius + setNeedsUpdateConfiguration() + } +} + +// MARK: GlassButton Factory + +extension GlassButton { + /// 기본 스타일의 GlassButton 인스턴스를 생성하여 반환합니다. + /// - Parameter title: 버튼에 표시될 텍스트 + /// - Returns: 설정이 완료된 GlassButton 인스턴스 + static func `default`(_ title: String) -> GlassButton { + let btn = GlassButton() + btn.configure( + title, + typography: .subtitle1, + border: Border(color: .color(UIColor.gray600), width: Constant.borderWidth), + backgroundColor: .color(UIColor.point200.withAlphaComponent(Constant.backgroundOpacity)), + foregroundColor: UIColor.gray900 + ) + + return btn + } + + /// 주 배경색(point600)이 적용된 기본 스타일의 GlassButton 인스턴스를 생성하여 반환합니다. + /// - Parameter title: 버튼에 표시될 텍스트 + /// - Returns: 설정이 완료된 GlassButton 인스턴스 + static func primary(_ title: String) -> GlassButton { + let btn = GlassButton() + btn.configure( + title, + typography: .subtitle1, + backgroundColor: .color(UIColor.point600), + foregroundColor: .white + ) + + return btn + } + + /// danger 배경색을 적용한 기본 스타일의 GlassButton 인스턴스를 생성하여 반환 합니다. + /// - Parameter title: 버튼에 표시될 텍스트 + /// - Returns: 설정이 완료된 GlassButton 인스턴스 + static func danger(_ title: String) -> GlassButton { + let btn = GlassButton() + btn.configure( + title, + typography: .subtitle1, + backgroundColor: .color(UIColor.danger), + foregroundColor: .white + ) + return btn + } + + /// 취소, 닫기 등 보조적인 액션을 위한 회색 계열(gray300)의 GlassButton 인스턴스를 생성하여 반환합니다. + /// - Parameter title: 버튼에 표시될 텍스트 + /// - Returns: 설정이 완료된 GlassButton 인스턴스 + static func close(_ title: String) -> GlassButton { + let btn = GlassButton() + btn.configure( + title, + typography: .body1, + backgroundColor: .color(UIColor.gray300), + foregroundColor: UIColor.gray750 + ) + return btn + } + + /// 64x64 크기의 둥근 플로팅 액션 버튼(FAB) 형태인 GlassButton 인스턴스를 생성하여 반환합니다. + /// 배경과 테두리에 기본적으로 음성 녹음 관련 그라데이션 컬러 매핑이 적용되어 있습니다. + /// + /// - Parameter image: 버튼 중앙에 표시할 아이콘 이미지 (`ImageAsset`) + /// - Returns: 기본 제약조건(Width, Height) 및 그라데이션 스타일이 적용된 GlassButton 인스턴스 + static func floating(image: ImageAsset) -> GlassButton { + let btn = GlassButton() + btn.configure( + nil, + typography: .body1, + border: .init(color: .gradient([.point900, .point1000]), width: 1), + image: image, + backgroundColor: .gradient([.point800, .point600]), + foregroundColor: UIColor.gray950 + ) + btn.widthAnchor.constraint(equalToConstant: Constant.floatingButtonSize).isActive = true + btn.heightAnchor.constraint(equalToConstant: Constant.floatingButtonSize).isActive = true + + return btn + } +} + +// MARK: Data 구조 + +extension GlassButton { + struct Border { + let color: GradientSet + let width: CGFloat + } + + struct ImageAsset { + let imageName: String + let type: GlassImageType + let configuration: UIImage.SymbolConfiguration? + + init(imageName: String, type: GlassImageType, configuration: UIImage.SymbolConfiguration? = nil) { + self.imageName = imageName + self.type = type + self.configuration = configuration + } + } + + enum GlassImageType { + case resource + case system + } + + enum GradientSet { + case color(UIColor) + case gradient([UIColor]) + } +} + +// MARK: - Gradient 커스텀 뷰 + +private final class UnifiedGradientView: UIView { + private let backgroundGradientLayer = CAGradientLayer() + private let borderGradientLayer = CAGradientLayer() + private let borderMaskLayer = CAShapeLayer() + + private var currentCornerRadius: CGFloat + + init( + bgColor: UIColor?, + bgColors: [UIColor]?, + borderColors: [UIColor]?, + borderWidth: CGFloat, + cornerRadius: CGFloat + ) { + currentCornerRadius = cornerRadius + super.init(frame: .zero) + isUserInteractionEnabled = false + + if let bgColor { + backgroundColor = bgColor + } + + if let bgColors { + backgroundGradientLayer.colors = bgColors.map(\.cgColor) + backgroundGradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + backgroundGradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + layer.addSublayer(backgroundGradientLayer) + } + + if let borderColors { + borderGradientLayer.colors = borderColors.map(\.cgColor) + borderGradientLayer.startPoint = CGPoint(x: 0.5, y: 1) + borderGradientLayer.endPoint = CGPoint(x: 0.5, y: 0) + + borderMaskLayer.fillColor = UIColor.clear.cgColor + borderMaskLayer.strokeColor = UIColor.black.cgColor + borderMaskLayer.lineWidth = borderWidth + borderGradientLayer.mask = borderMaskLayer + + layer.addSublayer(borderGradientLayer) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + func updateCornerRadius(_ radius: CGFloat) { + currentCornerRadius = radius + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + + if backgroundGradientLayer.superlayer != nil { + backgroundGradientLayer.frame = bounds + backgroundGradientLayer.cornerRadius = currentCornerRadius + } + + if borderGradientLayer.superlayer != nil { + borderGradientLayer.frame = bounds + let inset = borderMaskLayer.lineWidth / 2 + let path = UIBezierPath( + roundedRect: bounds.insetBy(dx: inset, dy: inset), + cornerRadius: max(currentCornerRadius - inset, 0) + ) + borderMaskLayer.path = path.cgPath + } + } +} + +// MARK: Alert Apply Factory + +extension GlassButton { + func apply(_ style: ChaGokAlertViewModel.ButtonStyle) { + switch style.type { + case .close: + configure( + style.text, + typography: .body1, + backgroundColor: .color(.gray300), + foregroundColor: .gray750 + ) + case .default: + configure( + style.text, + typography: .subtitle1, + border: Border(color: .color(.gray600), width: Constant.borderWidth), + backgroundColor: .color(.point200.withAlphaComponent(Constant.backgroundOpacity)), + foregroundColor: .gray900 + ) + case .primary: + configure( + style.text, + typography: .subtitle1, + backgroundColor: .color(.point600), + foregroundColor: .white + ) + case .danger: + configure( + style.text, + typography: .subtitle1, + backgroundColor: .color(.danger), + foregroundColor: .white + ) + } + } +} diff --git a/Presentation/Sources/Component/Common/LanguagePicker.swift b/Presentation/Sources/Component/Common/LanguagePicker.swift new file mode 100644 index 00000000..ca7a836e --- /dev/null +++ b/Presentation/Sources/Component/Common/LanguagePicker.swift @@ -0,0 +1,191 @@ +import Core +import Domain +import UIKit + +/// 언어 선택을 위한 라디오 버튼 스타일의 피커 컴포넌트입니다. +public final class LanguagePicker: UIStackView { + // MARK: - State + + private(set) var selectedLanguage: Language + var onLanguageChanged: ((Language) -> Void)? + var showAlert: Bool + + private var itemViews: [LanguageItemView] = [] + + // MARK: - LifeCycle + + public init(selected: Language, axis: NSLayoutConstraint.Axis = .vertical, showAlert: Bool = false) { + selectedLanguage = selected + self.showAlert = showAlert + super.init(frame: .zero) + setup(axis: axis) + createItems() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Set up + + private func setup(axis: NSLayoutConstraint.Axis) { + self.axis = axis + spacing = axis == .horizontal ? 12 : Constant.languagePickerSpacing + alignment = .fill + distribution = .fill + translatesAutoresizingMaskIntoConstraints = false + } + + // MARK: - Helper + + private func createItems() { + let leftSpacer = UIView() + let rightSpacer = UIView() + + if axis == .horizontal { + leftSpacer.translatesAutoresizingMaskIntoConstraints = false + rightSpacer.translatesAutoresizingMaskIntoConstraints = false + addArrangedSubview(leftSpacer) + } + + for language in Language.allCases { + let itemView = LanguageItemView( + language: language, + isSelected: language == selectedLanguage, + showAlert: showAlert + ) + itemView.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(itemTapped(_:))) + ) + addArrangedSubview(itemView) + itemViews.append(itemView) + } + + if axis == .horizontal { + addArrangedSubview(rightSpacer) + leftSpacer.widthAnchor.constraint(equalTo: rightSpacer.widthAnchor).isActive = true + } + } + + @objc + private func itemTapped(_ gesture: UITapGestureRecognizer) { + guard let itemView = gesture.view as? LanguageItemView else { return } + let newLanguage = itemView.language + + guard newLanguage != selectedLanguage else { return } + + selectedLanguage = newLanguage + updateSelectionState() + onLanguageChanged?(newLanguage) + } + + // MARK: - Update Properties + + func setLanguage(_ language: Language) { + selectedLanguage = language + updateSelectionState() + } + + private func updateSelectionState() { + itemViews.forEach { $0.setSelected($0.language == selectedLanguage, showAlert: showAlert) } + } +} + +// MARK: - Internal Item + +private final class LanguageItemView: UIView { + // MARK: - State + + let language: Language + private(set) var isSelected: Bool + + // MARK: - Component + + private let indicatorView: UIView = { + let v = UIView() + v.translatesAutoresizingMaskIntoConstraints = false + v.layer.cornerRadius = Constant.languagePickerIndicatorSize / 2 + v.clipsToBounds = true + v.backgroundColor = .gray900 + return v + }() + + private let innerIndicatorView: UIView = { + let v = UIView() + v.translatesAutoresizingMaskIntoConstraints = false + v.layer.cornerRadius = Constant.languagePickerInnerIndicatorSize / 2 + return v + }() + + private let titleLabel: UILabel = { + let l = UILabel() + l.translatesAutoresizingMaskIntoConstraints = false + l.textColor = .gray950 + return l + }() + + // MARK: - LifeCycle + + init(language: Language, isSelected: Bool, showAlert: Bool) { + self.language = language + self.isSelected = isSelected + super.init(frame: .zero) + setUp() + setupConstraints() + setSelected(isSelected, showAlert: showAlert) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Constraints + + private func setUp() { + indicatorView.addSubview(innerIndicatorView) + addSubview(indicatorView) + addSubview(titleLabel) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + indicatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + indicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + indicatorView.widthAnchor.constraint(equalToConstant: Constant.languagePickerIndicatorSize), + indicatorView.heightAnchor.constraint(equalToConstant: Constant.languagePickerIndicatorSize), + + innerIndicatorView.centerXAnchor.constraint(equalTo: indicatorView.centerXAnchor), + innerIndicatorView.centerYAnchor.constraint(equalTo: indicatorView.centerYAnchor), + innerIndicatorView.widthAnchor.constraint(equalToConstant: Constant.languagePickerInnerIndicatorSize), + innerIndicatorView.heightAnchor.constraint(equalToConstant: Constant.languagePickerInnerIndicatorSize), + + titleLabel.leadingAnchor.constraint( + equalTo: indicatorView.trailingAnchor, + constant: Constant.languagePickerTitleSpacing + ), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + titleLabel.topAnchor.constraint(equalTo: topAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Update Properties + + private func languageText(showAlert: Bool = false) -> String { + switch language { + case .ko: + return "한국어\(showAlert ? "" : " (기본설정)")" + case .en: + return "영어" + } + } + + func setSelected(_ selected: Bool, showAlert: Bool) { + isSelected = selected + innerIndicatorView.backgroundColor = selected ? .point600 : .gray900 + titleLabel.textColor = selected ? .gray900 : .gray750 + titleLabel.setTypography(text: languageText(showAlert: showAlert), style: .subtitle1) + } +} diff --git a/Presentation/Sources/Component/Common/LanguagePickertAlert.swift b/Presentation/Sources/Component/Common/LanguagePickertAlert.swift new file mode 100644 index 00000000..c431a4e0 --- /dev/null +++ b/Presentation/Sources/Component/Common/LanguagePickertAlert.swift @@ -0,0 +1,155 @@ +import UIKit + +final class LanguagePickerAlert: UIView { + let closeButton: GlassButton + let primaryButton: GlassButton + private let title: String + private var widthConstraint: NSLayoutConstraint? + + private let topContent: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 24 + view.distribution = .fill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let bottomContent: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.distribution = .fill + view.spacing = Constant.alertBottomContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var header: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.setTypography(text: title, style: .title2) + t.textAlignment = .center + t.textColor = UIColor.gray950 + t.numberOfLines = 0 + return t + }() + + private let languagePicker: LanguagePicker + + init( + title: String, + languagePicker: LanguagePicker, + closeButton: GlassButton, + primaryButton: GlassButton, + frame: CGRect = .zero + ) { + self.title = title + self.closeButton = closeButton + self.languagePicker = languagePicker + self.primaryButton = primaryButton + super.init(frame: frame) + applyGlassEffect(tintColor: .point200.withAlphaComponent(0.2)) + setup() + setupButton() + childSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } +} + +// MARK: - LifeCycle + +extension LanguagePickerAlert { + override func didMoveToSuperview() { + super.didMoveToSuperview() + guard let superview else { + widthConstraint?.isActive = false + widthConstraint = nil + return + } + guard widthConstraint == nil else { return } + let width = widthAnchor.constraint(equalTo: superview.widthAnchor, multiplier: Constant.alertMultiplierWidth) + width.isActive = true + widthConstraint = width + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = Constant.cornerRadius + + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = Constant.shadowOpacity + layer.shadowOffset = CGSize(width: Constant.shadowOffsetWidth, height: Constant.shadowOffsetHeight) + layer.shadowRadius = Constant.cornerRadius + layer.shadowPath = + UIBezierPath( + roundedRect: bounds, + cornerRadius: Constant.cornerRadius + ).cgPath + } +} + +// MARK: - setUp + +extension LanguagePickerAlert { + /// LanguagePickertAlert의 전체 배경색, 테두리(border), 모서리 등 가장 기초적인 View 스타일을 설정합니다. + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = .point200.withAlphaComponent(Constant.backgroundOpacity) + layer.borderWidth = Constant.borderWidth + layer.borderColor = UIColor.gray600.cgColor + } + + /// LanguagePickertAlert에 맞게 외부 설정 값 상관 없이 내부에서 일관된 디자인을 처리합니다. + private func setupButton() { + // close + closeButton.setShadow(false) + closeButton.setCapsuleCornerRadius() + // primary + primaryButton.setShadow(false) + primaryButton.setCapsuleCornerRadius() + } + + /// LanguagePickertAlert 내부의 컴포넌트들(제목, 언어 설정, 버튼 등)을 StackView에 배치하고 + /// 오토레이아웃 제약 조건을 설정합니다. + private func childSetup() { + topContent.addArrangedSubview(header) + topContent.addArrangedSubview(languagePicker) + bottomContent.addArrangedSubview(closeButton) + bottomContent.addArrangedSubview(primaryButton) + addSubview(topContent) + addSubview(bottomContent) + + NSLayoutConstraint.activate([ + topContent.topAnchor.constraint(equalTo: topAnchor, constant: Constant.alertTopAndBottomValueForTopContent), + topContent.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: Constant.alertLeftAndRightValueForTopContent + ), + topContent.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -Constant.alertLeftAndRightValueForTopContent + ), + topContent.bottomAnchor.constraint( + equalTo: bottomContent.topAnchor, + constant: -Constant.alertTopAndBottomContentSpacing + ), + bottomContent.heightAnchor.constraint(equalToConstant: Constant.alertBottomContentHeight), + bottomContent.bottomAnchor.constraint( + equalTo: bottomAnchor, + constant: -Constant.alertTopAndBottomValueForTopContent + ), + bottomContent.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: Constant.alertLeftAndRightValueForBottomContent + ), + bottomContent.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -Constant.alertLeftAndRightValueForBottomContent + ) + ]) + } +} diff --git a/Presentation/Sources/Component/Common/NavigationItemButton.swift b/Presentation/Sources/Component/Common/NavigationItemButton.swift new file mode 100644 index 00000000..8face07f --- /dev/null +++ b/Presentation/Sources/Component/Common/NavigationItemButton.swift @@ -0,0 +1,86 @@ +import UIKit + +final class NavigationItemButton: UIButton { + typealias Attribute = [NSAttributedString.Key: Any] + + // MARK: - State + + private let normalItem: Item + private let selectedItem: Item + private let attributedString: Attribute + private let normalForegroundColor: UIColor + private let selectedForegroundColor: UIColor + + // MARK: - Initialize + + init( + normalItem: Item, + selectedItem: Item, + attributedString: Attribute, + normalForegroundColor: UIColor = .gray950, + selectedForegroundColor: UIColor = .gray950, + frame: CGRect = .zero + ) { + self.normalItem = normalItem + self.selectedItem = selectedItem + self.attributedString = attributedString + self.normalForegroundColor = normalForegroundColor + self.selectedForegroundColor = selectedForegroundColor + super.init(frame: .zero) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + private func setup() { + var config = UIButton.Configuration.plain() + config.contentInsets = .zero + config.automaticallyUpdateForSelection = false + configuration = config + + configurationUpdateHandler = { [weak self] button in + guard let self, var config = button.configuration else { return } + let symbolConfig = UIImage.SymbolConfiguration(weight: .bold) + + if button.isSelected { + config.image = selectedItem.imageName + .flatMap { UIImage(systemName: $0)?.withConfiguration(symbolConfig) } + config.title = selectedItem.title + config.baseForegroundColor = selectedForegroundColor + } else { + config.image = normalItem.imageName.flatMap { UIImage(systemName: $0)?.withConfiguration(symbolConfig) } + config.title = normalItem.title + config.baseForegroundColor = normalForegroundColor + } + + if let title = config.title { + config.attributedTitle = AttributedString( + title, + attributes: AttributeContainer(attributedString) + ) + } else { + config.attributedTitle = nil + } + config.imagePadding = 8 + config.background.backgroundColor = .clear + button.configuration = config + } + } +} + +// MARK: - Data 구조 + +extension NavigationItemButton { + struct Item { + let title: String? + let imageName: String? + + init(title: String? = nil, imageName: String? = nil) { + self.title = title + self.imageName = imageName + } + } +} diff --git a/Presentation/Sources/Component/Common/ProgressView/DefaultProgressView.swift b/Presentation/Sources/Component/Common/ProgressView/DefaultProgressView.swift new file mode 100644 index 00000000..abc7f0cf --- /dev/null +++ b/Presentation/Sources/Component/Common/ProgressView/DefaultProgressView.swift @@ -0,0 +1,55 @@ +import UIKit + +final class DefaultProgressView: UIProgressView { + // MARK: - Properties + + private var lastSize: CGSize = .zero + + // MARK: - Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + // MARK: - Setup + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + // 불변 프로그레스 바(ImmutableProgressView)와 일치하는 트랙 컬러 설정 + trackTintColor = .gray600 + // 코너 라운딩 적용 + layer.cornerRadius = 4 + clipsToBounds = true + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + + let cornerRadius = bounds.height / 2 + layer.cornerRadius = cornerRadius + + // UIProgressView의 트랙 및 프로그레스 채움 뷰 모두 코너 라운딩 처리 적용 + for subview in subviews { + subview.layer.cornerRadius = cornerRadius + subview.clipsToBounds = true + } + + // 프레임 크기가 유효하고, 이전 크기와 다를 때만 그라데이션 이미지를 새로 생성하여 적용 (성능 최적화) + if bounds.width > 0, bounds.height > 0, bounds.size != lastSize { + lastSize = bounds.size + + let colors: [UIColor] = [.gray700, .point1000] + if let gradientImage = UIImage(bounds: bounds, colors: colors, orientation: .horizontal) { + progressImage = gradientImage + } + } + } +} diff --git a/Presentation/Sources/Component/Common/ProgressView/ImmutableIndicator.swift b/Presentation/Sources/Component/Common/ProgressView/ImmutableIndicator.swift new file mode 100644 index 00000000..41a65c1d --- /dev/null +++ b/Presentation/Sources/Component/Common/ProgressView/ImmutableIndicator.swift @@ -0,0 +1,41 @@ +import UIKit + +/// 그라데이션이 채워진 프로그레스 바의 게이지 바(Indicator) 역할을 하는 커스텀 뷰. +/// 코너 라운딩 및 좌우 가로형 그라데이션을 가집니다. +/// 배경색: .gray600, 그라데이션: .gray600 -> .point1000 +public final class ImmutableIndicator: UIView { + private let gradientLayer: CAGradientLayer = { + let layer = CAGradientLayer() + layer.startPoint = CGPoint(x: 0.0, y: 0.5) // 가로 방향 시작 + layer.endPoint = CGPoint(x: 1.0, y: 0.5) // 가로 방향 종료 + layer.locations = [0, 1.0] + return layer + }() + + override public init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + layer.cornerRadius = 4 + clipsToBounds = true + layer.addSublayer(gradientLayer) + } + + override public func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + gradientLayer.colors = [ + UIColor.gray600.cgColor, + UIColor.point1000.cgColor, + UIColor.gray600.cgColor + ] + } +} diff --git a/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift new file mode 100644 index 00000000..0ca9a6aa --- /dev/null +++ b/Presentation/Sources/Component/Common/ProgressView/ImmutableProgressView.swift @@ -0,0 +1,147 @@ +import Core +import Domain +import UIKit + +/// 항상 가로 방향으로 30% 너비의 인디케이터가 우측으로 이동하며 부드럽게 생성되고 사라지는 키프레임 애니메이션이 반복되는 커스텀 프로그레스 바. +/// TrackView: .gray600 +/// IndicatorView: ImmutableIndicator +public final class ImmutableProgressView: UIView { + // MARK: - Component + + private let trackView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .gray600 + view.layer.cornerRadius = 4 + view.clipsToBounds = true + return view + }() + + private let indicatorView: ImmutableIndicator = ImmutableIndicator() + + // MARK: - Properties + + private var isAnimating = false + private var lastWidth: CGFloat = 0 + + // MARK: - LifeCycle + + override public init(frame: CGRect) { + super.init(frame: frame) + setup() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + setupHierarchy() + setupLayout() + } + + override public func layoutSubviews() { + super.layoutSubviews() + + // 가로 방향으로 스윕하며 이동하는 애니메이션 적용 및 레이아웃 루프 인터럽트 방지 + if bounds.width > 0 { + if bounds.width != lastWidth { + lastWidth = bounds.width + stopAnimation() + startAnimation() + } else { + startAnimation() + } + } + } +} + +// MARK: - Private Setup + +extension ImmutableProgressView { + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + } + + private func setupHierarchy() { + addSubview(trackView) + trackView.addSubview(indicatorView) + } + + private func setupLayout() { + NSLayoutConstraint.activate([ + // trackView + trackView.leadingAnchor.constraint(equalTo: leadingAnchor), + trackView.trailingAnchor.constraint(equalTo: trailingAnchor), + trackView.topAnchor.constraint(equalTo: topAnchor), + trackView.bottomAnchor.constraint(equalTo: bottomAnchor), + + // indicatorView (높이는 트랙과 동일, leading에 밀착) + indicatorView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor), + indicatorView.topAnchor.constraint(equalTo: trackView.topAnchor), + indicatorView.bottomAnchor.constraint(equalTo: trackView.bottomAnchor), + + // 항상 전체 트랙의 30% 너비 유지 + indicatorView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: 0.3) + ]) + } + + // MARK: - Update + + func updateIndicator() {} + + // MARK: - Animation + + private func startAnimation() { + let totalWidth = bounds.width + guard !isAnimating, totalWidth > 0 else { return } + isAnimating = true + + // 30% 너비이므로 가용 우측 이동 가능 거리는 70% + let maxTranslation = totalWidth * 0.7 + + animateIndicator(maxTranslation: maxTranslation) + } + + private func animateIndicator(maxTranslation: CGFloat) { + guard isAnimating else { return } + + // 출발점 초기화 (시작은 눈이 피로하지 않도록 완전히 투명하게 설정) + indicatorView.transform = .identity + indicatorView.alpha = 0.0 + + UIView.animateKeyframes( + withDuration: 1.8, + delay: 0.0, + options: [.calculationModeCubic], + animations: { + UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1.0) { + self.indicatorView.transform = CGAffineTransform(translationX: maxTranslation, y: 0) + } + + UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.25) { + self.indicatorView.alpha = 1.0 + } + + UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { + self.indicatorView.alpha = 0.0 + } + }, + completion: { [weak self] finished in + guard let self else { return } + Task { @MainActor in + if finished, self.isAnimating { + self.animateIndicator(maxTranslation: maxTranslation) + } + } + } + ) + } + + private func stopAnimation() { + isAnimating = false + indicatorView.layer.removeAllAnimations() + indicatorView.transform = .identity + indicatorView.alpha = 0.0 + } +} diff --git a/Presentation/Sources/Component/Common/TextFieldView.swift b/Presentation/Sources/Component/Common/TextFieldView.swift new file mode 100644 index 00000000..106af0ab --- /dev/null +++ b/Presentation/Sources/Component/Common/TextFieldView.swift @@ -0,0 +1,281 @@ +import UIKit + +public final class TextFieldView: UIView { + // MARK: - Properties + + var field: Field + + // MARK: - Componenet + + private let container: UIStackView = { + let c = UIStackView() + c.translatesAutoresizingMaskIntoConstraints = false + c.axis = .vertical + c.spacing = 12 + return c + }() + + private lazy var titleLabel: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.setTypography(text: field.title, style: .title2, textAlignment: .center) + t.textColor = .gray950 + return t + }() + + private lazy var subTitleLabel: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.setTypography(text: field.subTitle, style: .body2, textAlignment: .center) + t.textColor = .gray950 + t.numberOfLines = 0 + return t + }() + + private lazy var textField: UITextField = { + let tf = UITextField() + tf.translatesAutoresizingMaskIntoConstraints = false + tf.backgroundColor = .gray100 + tf.layer.cornerRadius = 8 + tf.textColor = .gray950 + tf.font = Typography.body1.font + tf.defaultTextAttributes = [ + .font: Typography.body1.font, + .foregroundColor: UIColor.gray950, + .kern: Typography.body1.letterSpacing + ] + tf.delegate = self + tf.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + tf.leftViewMode = .always + tf.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + tf.rightViewMode = .always + return tf + }() + + private lazy var placeholderLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: field.placeHolder, style: .body1) + label.textColor = .gray600 + label.numberOfLines = 0 + return label + }() + + private lazy var textCount: UILabel = { + let text = UILabel() + text.translatesAutoresizingMaskIntoConstraints = false + text.setTypography(text: field.textCountLabel, style: .label) + text.textColor = UIColor.gray750 + text.numberOfLines = 0 + text.setContentCompressionResistancePriority(.required, for: .vertical) + return text + }() + + private let bottomContainer: UIStackView = { + let c = UIStackView() + c.translatesAutoresizingMaskIntoConstraints = false + c.axis = .horizontal + c.spacing = 8 + + return c + }() + + private let cancelButton: GlassButton + private let primaryButton: GlassButton + + // MARK: - Initialize + + init( + field: Field, + cancelButton: GlassButton, + primaryButton: GlassButton + ) { + self.field = field + self.cancelButton = cancelButton + self.primaryButton = primaryButton + super.init(frame: .zero) + applyGlassEffect(tintColor: .gray200.withAlphaComponent(0.2)) + setup() + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func layoutSubviews() { + super.layoutSubviews() + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = Constant.shadowOpacity + layer.shadowOffset = CGSize(width: Constant.shadowOffsetWidth, height: Constant.shadowOffsetHeight) + layer.shadowRadius = Constant.cornerRadius + layer.shadowPath = + UIBezierPath( + roundedRect: bounds, + cornerRadius: Constant.cornerRadius + ).cgPath + } + + override public func updateProperties() { + super.updateProperties() + titleLabel.setTypography(text: field.title, style: .title2, textAlignment: .center) + subTitleLabel.setTypography(text: field.subTitle, style: .body2, textAlignment: .center) + placeholderLabel.setTypography(text: field.placeHolder, style: .body1) + + if textField.text != field.text { + textField.text = field.text + } + + switch field.mode { + case .create: + primaryButton.configuration?.title = "만들기" + case .edit: + primaryButton.configuration?.title = "수정하기" + } + updatePlaceholderVisibility() + // error Message + updateErrorMessageLabel() + } + + // MARK: - Setup + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + layer.cornerRadius = Constant.cornerRadius + layer.borderColor = UIColor.gray600.cgColor + layer.borderWidth = Constant.borderWidth + setupConstraint() + setupStyle() + } + + private func setupConstraint() { + bottomContainer.addArrangedSubview(cancelButton) + bottomContainer.addArrangedSubview(primaryButton) + container.addArrangedSubview(titleLabel) + container.addArrangedSubview(subTitleLabel) + container.addArrangedSubview(textField) + container.setCustomSpacing(8, after: textField) + container.addArrangedSubview(textCount) + container.setCustomSpacing(24, after: textCount) + container.addArrangedSubview(bottomContainer) + addSubview(container) + textField.addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor, constant: 32), + container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + container.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + container.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -32), + textField.heightAnchor.constraint(equalToConstant: 48), + placeholderLabel.centerYAnchor.constraint(equalTo: textField.centerYAnchor), + placeholderLabel.leadingAnchor.constraint(equalTo: textField.leadingAnchor, constant: 12), + placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: textField.trailingAnchor, constant: -12), + cancelButton.heightAnchor.constraint(equalToConstant: 46), + primaryButton.heightAnchor.constraint(equalToConstant: 46) + ]) + } + + private func setupStyle() { + cancelButton.setShadow(true) + cancelButton.setCapsuleCornerRadius() + primaryButton.setShadow(true) + primaryButton.setCapsuleCornerRadius() + updatePlaceholderVisibility() + } + + @objc + private func textFieldDidChange() { + field.text = textField.text ?? "" + field.errorMessage = nil + textCount.setTypography(text: field.textCountLabel, style: .label) + textCount.textColor = field.text.count >= 50 ? .danger : .gray750 + updatePlaceholderVisibility() + } +} + +// MARK: - Update Method + +extension TextFieldView { + private func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !field.text.isEmpty + } + + private func updateErrorMessageLabel() { + if let errorMessage = field.errorMessage { + textCount.setTypography(text: errorMessage, style: .label) + textCount.textColor = .danger + } else { + textCount.setTypography(text: field.textCountLabel, style: .label) + textCount.textColor = field.text.count >= 50 ? .danger : .gray750 + } + } + + public func setErrorMessage(_ message: String?) { + field.errorMessage = message + updateErrorMessageLabel() + } +} + +// MARK: - Observable 구조 + +public extension TextFieldView { + @Observable + final class Field { + var mode: Mode + var title: String + var subTitle: String + var placeHolder: String + var text: String + var errorMessage: String? + + var textCountLabel: String { + "\(text.count)/\(50)" + } + + var textCountOverCheck: Bool { + text.count > 50 + } + + public init( + mode: Mode, + title: String, + subTitle: String, + placeHolder: String, + text: String = "", + errorMessage: String? + ) { + self.mode = mode + self.title = title + self.subTitle = subTitle + self.placeHolder = placeHolder + self.text = text + self.errorMessage = errorMessage + } + } + + enum Mode { + case create + case edit + } +} + +// MARK: TextField Delegate + +extension TextFieldView: UITextFieldDelegate { + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + public func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + guard let currentText = textField.text as NSString? else { return true } + let updatedText = currentText.replacingCharacters(in: range, with: string) + return updatedText.count <= 50 + } +} diff --git a/Presentation/Sources/Component/Common/TypographyLabel.swift b/Presentation/Sources/Component/Common/TypographyLabel.swift new file mode 100644 index 00000000..66c4e10d --- /dev/null +++ b/Presentation/Sources/Component/Common/TypographyLabel.swift @@ -0,0 +1,70 @@ +import UIKit + +/// Typography를 생성자로 받아 텍스트 변경 시에도 타이포그래피 속성을 유지하는 UILabel 서브클래스. +/// UILabel의 `text` 세터가 `attributedText`를 덮어쓰면서 속성이 초기화되는 문제를 해결합니다. +public class TypographyLabel: UILabel { + public var typography: Typography { + didSet { applyTypography() } + } + + public var typographyAlignment: NSTextAlignment { + didSet { applyTypography() } + } + + override public var text: String? { + didSet { applyTypography() } + } + + override public var lineBreakMode: NSLineBreakMode { + didSet { applyTypography() } + } + + public init(typography: Typography, alignment: NSTextAlignment = .left) { + self.typography = typography + typographyAlignment = alignment + super.init(frame: .zero) + applyTypography() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + private func applyTypography() { + var attributes = typography.textAttributes + + if let paragraphStyle = (attributes[.paragraphStyle] as? NSParagraphStyle)? + .mutableCopy() as? NSMutableParagraphStyle + { + paragraphStyle.alignment = typographyAlignment + paragraphStyle.lineBreakMode = lineBreakMode + attributes[.paragraphStyle] = paragraphStyle + } + + super.attributedText = NSAttributedString(string: text ?? "", attributes: attributes) + } +} + +#Preview { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + stack.alignment = .leading + + let styles: [(Typography, String)] = [ + (.header1, "Header1"), (.header2, "Header2"), + (.title1, "Title1"), (.title2, "Title2"), (.title3, "Title3"), + (.subtitle1, "Subtitle1"), (.subtitle2, "Subtitle2"), + (.body1, "Body1"), (.body2, "Body2"), (.body3, "Body3"), + (.label, "Label"), (.caption, "Caption") + ] + + for (style, name) in styles { + let label = TypographyLabel(typography: style) + label.text = "\(name) - 타이포그래피 미리보기" + stack.addArrangedSubview(label) + } + + return stack +} diff --git a/Presentation/Sources/Component/Common/TypographyTextField.swift b/Presentation/Sources/Component/Common/TypographyTextField.swift new file mode 100644 index 00000000..5a5f4e8f --- /dev/null +++ b/Presentation/Sources/Component/Common/TypographyTextField.swift @@ -0,0 +1,103 @@ +import UIKit + +/// Typography를 생성자로 받아 텍스트 변경 시에도 타이포그래피 속성을 유지하는 UITextField 서브클래스. +/// 프로그래매틱 할당은 `attributedText`로, 편집 중 입력은 `defaultTextAttributes`로 속성을 유지합니다. +public class TypographyTextField: UITextField { + public var typography: Typography { + didSet { applyTypography() } + } + + public var typographyAlignment: NSTextAlignment { + didSet { + textAlignment = typographyAlignment + applyTypography() + } + } + + override public var text: String? { + didSet { applyTypography() } + } + + override public var textColor: UIColor? { + didSet { applyTypography() } + } + + public init(typography: Typography, alignment: NSTextAlignment = .left) { + self.typography = typography + typographyAlignment = alignment + super.init(frame: .zero) + textAlignment = alignment + contentVerticalAlignment = .center + applyTypography() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override public func textRect(forBounds bounds: CGRect) -> CGRect { + bounds + } + + override public func editingRect(forBounds bounds: CGRect) -> CGRect { + bounds + } + + override public func placeholderRect(forBounds bounds: CGRect) -> CGRect { + bounds + } + + private func applyTypography() { + var attributes = typography.textAttributes + + if let paragraphStyle = (attributes[.paragraphStyle] as? NSParagraphStyle)? + .mutableCopy() as? NSMutableParagraphStyle + { + paragraphStyle.alignment = typographyAlignment + attributes[.paragraphStyle] = paragraphStyle + } + + // UILabel은 baselineOffset을 이중 적용하지만 UITextField는 단일 적용하므로, + // 동일한 속성을 주면 Label 대비 텍스트가 아래로 밀린다. + // 2배로 보정하여 Label과 동일한 수직 위치를 맞춘다. + if let offset = attributes[.baselineOffset] as? CGFloat { + attributes[.baselineOffset] = offset * 2 + } + + if let textColor { + attributes[.foregroundColor] = textColor + } + + super.attributedText = NSAttributedString(string: text ?? "", attributes: attributes) + defaultTextAttributes = attributes + } +} + +#Preview { + let viewController = UIViewController() + viewController.view.backgroundColor = .systemBackground + + let displayField = TypographyTextField(typography: .title1) + displayField.text = "Title1 — 표시 전용" + displayField.isEnabled = false + displayField.textColor = .gray950 + + let editingField = TypographyTextField(typography: .body1) + editingField.text = "Body1 — 편집 가능" + editingField.textColor = .gray950 + + let stack = UIStackView(arrangedSubviews: [displayField, editingField]) + stack.axis = .vertical + stack.spacing = 16 + stack.translatesAutoresizingMaskIntoConstraints = false + viewController.view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: viewController.view.centerYAnchor) + ]) + + return viewController +} diff --git a/Presentation/Sources/Component/Common/TypographyTextView.swift b/Presentation/Sources/Component/Common/TypographyTextView.swift new file mode 100644 index 00000000..7105555b --- /dev/null +++ b/Presentation/Sources/Component/Common/TypographyTextView.swift @@ -0,0 +1,81 @@ +import UIKit + +/// Typography를 생성자로 받아 텍스트 변경 시에도 타이포그래피 속성을 유지하는 UITextView 서브클래스. +/// 프로그래매틱 할당은 `attributedText`로, 편집 중 입력은 `typingAttributes`로 속성을 유지합니다. +public class TypographyTextView: UITextView { + public var typography: Typography { + didSet { applyTypography() } + } + + public var typographyAlignment: NSTextAlignment { + didSet { applyTypography() } + } + + override public var text: String! { + didSet { applyTypography() } + } + + override public var textColor: UIColor? { + didSet { applyTypography() } + } + + public init(typography: Typography, alignment: NSTextAlignment = .left) { + self.typography = typography + typographyAlignment = alignment + super.init(frame: .zero, textContainer: nil) + applyTypography() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + private func applyTypography() { + var attributes = typography.textAttributes + + if let paragraphStyle = (attributes[.paragraphStyle] as? NSParagraphStyle)? + .mutableCopy() as? NSMutableParagraphStyle + { + paragraphStyle.alignment = typographyAlignment + attributes[.paragraphStyle] = paragraphStyle + } + + if let textColor { + attributes[.foregroundColor] = textColor + } + + super.attributedText = NSAttributedString(string: text ?? "", attributes: attributes) + typingAttributes = attributes + } +} + +#Preview { + let viewController = UIViewController() + viewController.view.backgroundColor = .systemBackground + + let displayTextView = TypographyTextView(typography: .body1) + displayTextView.text = "Body1 — 표시 전용 텍스트입니다.\n줄바꿈과 행간을 확인합니다." + displayTextView.isEditable = false + displayTextView.isScrollEnabled = false + displayTextView.backgroundColor = .gray100 + + let editingTextView = TypographyTextView(typography: .body2) + editingTextView.text = "Body2 — 편집 모드에서 typingAttributes로 스타일이 유지됩니다." + editingTextView.isScrollEnabled = false + editingTextView.backgroundColor = .gray50 + + let stack = UIStackView(arrangedSubviews: [displayTextView, editingTextView]) + stack.axis = .vertical + stack.spacing = 16 + stack.translatesAutoresizingMaskIntoConstraints = false + viewController.view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: viewController.view.centerYAnchor) + ]) + + return viewController +} diff --git a/Presentation/Sources/Component/Common/UnderlineSegmentedControl.swift b/Presentation/Sources/Component/Common/UnderlineSegmentedControl.swift new file mode 100644 index 00000000..d1e2a321 --- /dev/null +++ b/Presentation/Sources/Component/Common/UnderlineSegmentedControl.swift @@ -0,0 +1,69 @@ +import UIKit + +final class UnderlineSegmentedControl: UIView { + var onSegmentSelected: ((Int) -> Void)? + + private(set) var selectedSegmentIndex: Int = 0 + + private let stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.distribution = .fillEqually + return stack + }() + + private var buttons: [UnderlineTabButton] = [] + + // MARK: - Init + + init(items: [String]) { + super.init(frame: .zero) + setupButtons(items: items) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: Constant.underlineSegmentedControlHeight) + } + + // MARK: - Setup + + private func setupButtons(items: [String]) { + buttons = items.enumerated().map { index, title in + let button = UnderlineTabButton(title: title, isSelected: index == selectedSegmentIndex) + button.addAction(UIAction { [weak self] _ in + self?.selectSegment(index: index) + self?.onSegmentSelected?(index) + }, for: .touchUpInside) + return button + } + } + + private func setupUI() { + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + buttons.forEach { stackView.addArrangedSubview($0) } + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + func selectSegment(index: Int, animated: Bool = true) { + selectedSegmentIndex = index + for (idx, button) in buttons.enumerated() { + button.setSelected(idx == index, animated: animated) + } + } + + func setCount(_ count: Int?, at index: Int) { + guard buttons.indices.contains(index) else { return } + buttons[index].setCount(count) + } +} diff --git a/Presentation/Sources/Component/Common/UnderlineTabButton.swift b/Presentation/Sources/Component/Common/UnderlineTabButton.swift new file mode 100644 index 00000000..e8a6a645 --- /dev/null +++ b/Presentation/Sources/Component/Common/UnderlineTabButton.swift @@ -0,0 +1,77 @@ +import UIKit + +final class UnderlineTabButton: UIButton { + private let tabTitleLabel = TypographyLabel(typography: .body1, alignment: .center) + private let countLabel: TypographyLabel = { + let label = TypographyLabel(typography: .title3, alignment: .center) + label.textColor = UIColor.point700 + label.isHidden = true + return label + }() + + private lazy var contentStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [tabTitleLabel, countLabel]) + stack.axis = .horizontal + stack.spacing = Constant.underlineTabContentSpacing + stack.alignment = .center + stack.isUserInteractionEnabled = false + return stack + }() + + private let indicator: UIView = { + let view = UIView() + view.backgroundColor = UIColor.point700 + view.isUserInteractionEnabled = false + return view + }() + + // MARK: - Init + + init(title: String, isSelected: Bool = false) { + super.init(frame: .zero) + tabTitleLabel.text = title + setupUI() + setSelected(isSelected, animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + // MARK: - Setup + + private func setupUI() { + addSubview(contentStack) + addSubview(indicator) + + for subview in [contentStack, indicator] { + subview.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + contentStack.centerXAnchor.constraint(equalTo: centerXAnchor), + contentStack.centerYAnchor.constraint(equalTo: centerYAnchor), + + indicator.leadingAnchor.constraint(equalTo: leadingAnchor), + indicator.trailingAnchor.constraint(equalTo: trailingAnchor), + indicator.bottomAnchor.constraint(equalTo: bottomAnchor), + indicator.heightAnchor.constraint(equalToConstant: Constant.underlineTabIndicatorHeight) + ]) + } + + func setSelected(_ isSelected: Bool, animated: Bool = true) { + self.isSelected = isSelected + indicator.isHidden = !isSelected + tabTitleLabel.typography = isSelected ? .title3 : .body1 + tabTitleLabel.textColor = isSelected ? UIColor.gray950 : UIColor.gray600 + } + + func setCount(_ count: Int?) { + if let count { + countLabel.text = "\(count)" + countLabel.isHidden = false + } else { + countLabel.text = nil + countLabel.isHidden = true + } + } +} diff --git a/Presentation/Sources/Component/Folder/FolderCardView.swift b/Presentation/Sources/Component/Folder/FolderCardView.swift new file mode 100644 index 00000000..61693a47 --- /dev/null +++ b/Presentation/Sources/Component/Folder/FolderCardView.swift @@ -0,0 +1,98 @@ +import Domain +import SwiftUI + +struct FolderCardView: View { + let isSelected: Bool + var select: SelectionMode + let folder: Folder + let action: ((Folder, Bool) -> Void)? + let completeAction: (() -> Void)? + + init( + select: SelectionMode = .none, + isSelected: Bool = false, + folder: Folder, + action: ((Folder, Bool) -> Void)? = nil, + completeAction: (() -> Void)? = nil + ) { + self.select = select + self.isSelected = isSelected + self.folder = folder + self.action = action + self.completeAction = completeAction + } + + var isEdit: Bool { + select != .none + } + + var body: some View { + HStack(spacing: 0) { + if isEdit { + checkIcon + } + cardContent + } + .editfolderCardStyle(isSelected: isSelected) + .onTapGesture { + if isEdit { + action?(folder, !isSelected) + } else { + completeAction?() + } + } + } + + private var checkIcon: some View { + VStack(alignment: .center, spacing: 0) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.gray950, .point600) + .overlay { + if !isSelected { + Circle() + .fill(.gray850) + } + } + } + .padding(.trailing) + } + + private var cardContent: some View { + HStack(spacing: 8) { + Group { + Image(systemName: "folder") + Text(folder.name) + .typography(.body2) + Spacer() + Text(String(folder.voiceNoteIDs.count)) + .typography(.body2) + .multilineTextAlignment(.trailing) + } + .foregroundColor(.gray800) + } + } +} + +extension View { + func editfolderCardStyle(isSelected: Bool) -> some View { + modifier(EditFolderCardModifier(isSelected: isSelected)) + } +} + +struct EditFolderCardModifier: ViewModifier { + let isSelected: Bool + + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .glassEffect(.clear.tint(.point200.opacity(0.2)), in: .rect(cornerRadius: 20)) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 20) + .stroke(.point900, lineWidth: 1) + } + } + .contentShape(.rect(cornerRadius: 20)) + } +} diff --git a/Presentation/Sources/Component/Folder/SearchFolderCardView.swift b/Presentation/Sources/Component/Folder/SearchFolderCardView.swift new file mode 100644 index 00000000..d79f47fb --- /dev/null +++ b/Presentation/Sources/Component/Folder/SearchFolderCardView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct SearchFolderCardView: View { + let fullText: String + let keyword: String + let createdAt: String + let voiceNoteCount: Int + let action: () -> Void + + var body: some View { + HStack(spacing: 16) { + Image(systemName: "folder") + .frame(maxWidth: 20, maxHeight: 20) + VStack(alignment: .leading, spacing: 12) { + Text( + fullText: fullText, + keyword: keyword + ) + .typography(.title3) + Text(createdAt) + .typography(.label) + .foregroundStyle(.gray750) + } + Spacer() + Text(String(voiceNoteCount)) + .typography(.body2) + .foregroundStyle(.gray750) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .glassEffect(.clear.tint(.point200.opacity(0.2)), in: .rect(cornerRadius: Constant.cornerRadius)) + .contentShape(.rect(cornerRadius: Constant.cornerRadius)) + .onTapGesture { action() } + } +} + +#Preview { + SearchFolderCardView( + fullText: "가을 하늘 맑고 푸른데", + keyword: "맑고", + createdAt: Date.now.description, + voiceNoteCount: 3, + action: {} + ) + .padding() +} diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingCardView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingCardView.swift new file mode 100644 index 00000000..6d9b8f20 --- /dev/null +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingCardView.swift @@ -0,0 +1,91 @@ +import Core +import UIKit + +final class OnBoardingCardView: UIStackView { + // MARK: - State + + private let headlineText: String + private let bodyText: String + private let image: UIImage? + + // MARK: - Component + + private lazy var headlineLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: headlineText, style: .header1) + label.numberOfLines = Constant.onBoardingLabelNumberOfLines + label.textColor = .gray950 + return label + }() + + private lazy var bodyLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: bodyText, style: .subtitle1) + label.numberOfLines = Constant.onBoardingLabelNumberOfLines + label.textColor = .gray950 + return label + }() + + private lazy var imageView: UIImageView = { + let iv = UIImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.image = image + iv.contentMode = .scaleAspectFit + return iv + }() + + private let imageContainer = UIView() + + // MARK: - LifeCycle + + init(headline: String, body: String, image: UIImage?, frame: CGRect = .zero) { + headlineText = headline + bodyText = body + self.image = image + super.init(frame: frame) + setup() + setupHierarchy() + setupConstraints() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Set up + +extension OnBoardingCardView { + private func setup() { + 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) + } + + private func setupHierarchy() { + addArrangedSubview(headlineLabel) + addArrangedSubview(bodyLabel) + setCustomSpacing(Constant.onBoardingCardImageTopSpacing, after: bodyLabel) + + imageContainer.addSubview(imageView) + addArrangedSubview(imageContainer) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: imageContainer.topAnchor), + imageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor), + imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor), + imageView.leadingAnchor.constraint(greaterThanOrEqualTo: imageContainer.leadingAnchor) + ]) + } +} diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift new file mode 100644 index 00000000..5267ce7d --- /dev/null +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingDownloadView.swift @@ -0,0 +1,92 @@ +import Core +import Domain +import SwiftUI +import UIKit + +final class OnBoardingDownloadView: UIStackView { + // MARK: - State + + var vm: OnBoardingViewModel + private let headlineText: String + private let bodyText: String + + // MARK: - Component + + private lazy var headlineLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: headlineText, style: .header1) + label.numberOfLines = Constant.onBoardingLabelNumberOfLines + label.textColor = .gray950 + return label + }() + + private lazy var bodyLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = Constant.onBoardingLabelNumberOfLines + label.setTypography(text: bodyText, style: .subtitle1) + label.textColor = .gray950 + return label + }() + + private lazy var downloadModelCard = DownloadModelCard( + symbolName: "externaldrive", + modelName: "Gemma-4", + style: .immutable, + storage: vm.status.storage + ) + + /// 남는 수직 공간을 흡수하는 빈 뷰 (OnBoardingCardView의 imageContainer 역할) + private let spacerView = UIView() + + // MARK: - LifeCycle + + init( + headline: String, + body: String, + vm: OnBoardingViewModel, + frame: CGRect = .zero + ) { + headlineText = headline + bodyText = body + self.vm = vm + super.init(frame: frame) + setup() + setupHierarchy() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateProperties() { + super.updateProperties() + let storage = vm.status.storage + downloadModelCard.updateStatus(storage, errorMessage: vm.errorMessage) + } +} + +// MARK: - Set up + +extension OnBoardingDownloadView { + private func setup() { + 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) + } + + private func setupHierarchy() { + addArrangedSubview(headlineLabel) + addArrangedSubview(bodyLabel) + addArrangedSubview(downloadModelCard) + addArrangedSubview(spacerView) + } +} diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingFinishView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingFinishView.swift new file mode 100644 index 00000000..8ef82e6a --- /dev/null +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingFinishView.swift @@ -0,0 +1,86 @@ +import Core +import Domain +import UIKit + +final class OnBoardingFinishView: UIStackView { + // MARK: - State + + private let headlineText: String + private let bodyText: String + + // MARK: - Component + + private lazy var headlineLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: headlineText, style: .header1) + label.numberOfLines = Constant.onBoardingLabelNumberOfLines + label.textColor = .gray950 + return label + }() + + private lazy var bodyLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: bodyText, style: .subtitle1) + label.numberOfLines = Constant.onBoardingLabelNumberOfLines + label.textColor = .gray950 + return label + }() + + private let languagePicker: LanguagePicker + private var onLanguageChanged: ((Language) -> Void)? + + /// 남는 수직 공간을 흡수하는 빈 뷰 (OnBoardingCardView의 imageContainer 역할) + private let spacerView = UIView() + + // MARK: - LifeCycle + + init( + headline: String, + body: String, + selectedLanguage: Language, + onLanguageChanged: ((Language) -> Void)? = nil, + frame: CGRect = .zero + ) { + headlineText = headline + bodyText = body + languagePicker = .init(selected: selectedLanguage) + self.onLanguageChanged = onLanguageChanged + super.init(frame: frame) + setup() + setupHierarchy() + + languagePicker.onLanguageChanged = { [weak self] lang in + self?.onLanguageChanged?(lang) + } + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Set up + +extension OnBoardingFinishView { + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + axis = .vertical + spacing = Constant.onBoardingContentSpacing + + // headline·body는 intrinsic size만 차지하고, + // 남는 수직 공간은 imageContainer가 흡수하도록 설정 + headlineLabel.setContentHuggingPriority(.required, for: .vertical) + bodyLabel.setContentHuggingPriority(.required, for: .vertical) + spacerView.setContentHuggingPriority(.defaultLow, for: .vertical) + } + + private func setupHierarchy() { + addArrangedSubview(headlineLabel) + addArrangedSubview(bodyLabel) + addArrangedSubview(languagePicker) + addArrangedSubview(spacerView) + } +} diff --git a/Presentation/Sources/Component/OnBoarding/OnBoardingPagingView.swift b/Presentation/Sources/Component/OnBoarding/OnBoardingPagingView.swift new file mode 100644 index 00000000..3cfdc567 --- /dev/null +++ b/Presentation/Sources/Component/OnBoarding/OnBoardingPagingView.swift @@ -0,0 +1,60 @@ +import Core +import UIKit + +final class OnBoardingPagingView: UIScrollView { + // MARK: - Component + + private let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fill + stackView.alignment = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + // MARK: - LifeCycle + + init(pages: [UIView]) { + super.init(frame: .zero) + setup() + configure(pages: pages) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } +} + +// MARK: - Set up + +extension OnBoardingPagingView { + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + isPagingEnabled = true + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + + addSubview(containerStackView) + + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor), + containerStackView.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor) + ]) + } + + private func configure(pages: [UIView]) { + for cardView in pages { + containerStackView.addArrangedSubview(cardView) + + // 각 카드의 너비를 자신(UIScrollView)의 프레임과 일치시켜 페이징 구현 + NSLayoutConstraint.activate([ + cardView.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor) + ]) + } + } +} diff --git a/Presentation/Sources/Component/OnBoarding/Pagenation.swift b/Presentation/Sources/Component/OnBoarding/Pagenation.swift new file mode 100644 index 00000000..cd0747e1 --- /dev/null +++ b/Presentation/Sources/Component/OnBoarding/Pagenation.swift @@ -0,0 +1,69 @@ +import Foundation +import UIKit + +final class Pagenation: UIStackView { + /// 현재 활성화된 인덱스를 저장합니다 (0부터 시작) + var currentIndex: Int { + didSet { + setNeedsLayout() + } + } + + private let maxIndex: Int + private let indicatorView = UIView() + + init( + currentIndex: Int, + maxIndex: Int, + frame: CGRect = .zero + ) { + self.currentIndex = currentIndex + self.maxIndex = maxIndex + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + axis = .horizontal + spacing = Constant.pagenationSpacing + distribution = .fillEqually + alignment = .center + translatesAutoresizingMaskIntoConstraints = false + + for _ in 0 ..< maxIndex { + let step = createStep() + step.backgroundColor = UIColor.gray400 // 기본 배경색 + addArrangedSubview(step) + } + + indicatorView.backgroundColor = UIColor.gray950 + addSubview(indicatorView) + } + + override func layoutSubviews() { + super.layoutSubviews() + guard arrangedSubviews.indices.contains(currentIndex) else { return } + + // 인디케이터가 현재 스텝의 프레임을 따라가도록 설정 + indicatorView.frame = arrangedSubviews[currentIndex].frame + } +} + +// MARK: - Helper 함수 ( Step UIView ) + +extension Pagenation { + /// Pagenation을 구성하는 개별 스텝 뷰(선)를 생성하여 반환합니다. + /// 높이 제약조건이 내부적으로 함께 설정됩니다. + private func createStep() -> UIView { + let step = UIView() + step.translatesAutoresizingMaskIntoConstraints = false + step.heightAnchor.constraint(equalToConstant: Constant.pagenationHeight).isActive = true + + return step + } +} diff --git a/Presentation/Sources/Component/Recording/OnDeviceInfoBox.swift b/Presentation/Sources/Component/Recording/OnDeviceInfoBox.swift new file mode 100644 index 00000000..704a82e1 --- /dev/null +++ b/Presentation/Sources/Component/Recording/OnDeviceInfoBox.swift @@ -0,0 +1,82 @@ +import UIKit + +/// 온디바이스 Whisper 다운로드 시트에서 보여줄 정보입니다. +final class OnDeviceInfoBox: UIStackView { + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + let data: [(symbolName: String, text: String)] = [ + (symbolName: "interfaceLockShield", text: "녹음한 목소리가 기기 밖으로 나가지 않아요"), + (symbolName: "cloudOff", text: "인터넷 없이도 받아쓰기와 요약이 가능해요"), + (symbolName: "entertainmentRecording", text: "길이 제한 없이 기록 할 수 있어요") + ] + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + spacing = 16 + axis = .vertical + isLayoutMarginsRelativeArrangement = true + layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + applyGlassEffect(tintColor: .point200.withAlphaComponent(0.2)) + for (symbolName, text) in data { + let info = createLabel(symbolName: symbolName, text: text) + addArrangedSubview(info) + } + } +} + +// MARK: - Helper + +extension OnDeviceInfoBox { + func createLabel(symbolName: String, text: String) -> UIStackView { + let imageView = UIImageView() + let label = UILabel() + + for item in [imageView, label] { + item.translatesAutoresizingMaskIntoConstraints = false + } + + let sizeConfig = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold) + let colorConfig = UIImage.SymbolConfiguration(paletteColors: [.point600]) + let combinedConfig = sizeConfig.applying(colorConfig) + + if let systemImage = UIImage(systemName: symbolName, withConfiguration: combinedConfig) { + imageView.image = systemImage + } else { + let bundle = Bundle(for: OnDeviceInfoBox.self) + if let assetImage = UIImage(named: symbolName, in: bundle, with: nil) { + imageView.image = assetImage.withRenderingMode(.alwaysTemplate) + imageView.tintColor = .point600 + } else if let assetImage = UIImage(named: "icon/\(symbolName)", in: bundle, with: nil) { + imageView.image = assetImage.withRenderingMode(.alwaysTemplate) + imageView.tintColor = .point600 + } + } + imageView.contentMode = .scaleAspectFit + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 20), + imageView.heightAnchor.constraint(equalToConstant: 20) + ]) + + // label + label.setTypography(text: text, style: .body1) + label.textColor = .gray950 + // spacer + let spacer = UIView() + // container + let container = UIStackView(arrangedSubviews: [imageView, label, spacer]) + container.axis = .horizontal + container.alignment = .center + container.spacing = 8 + + return container + } +} diff --git a/Presentation/Sources/Component/Trash/TrashFolderCardView.swift b/Presentation/Sources/Component/Trash/TrashFolderCardView.swift new file mode 100644 index 00000000..af7c4af5 --- /dev/null +++ b/Presentation/Sources/Component/Trash/TrashFolderCardView.swift @@ -0,0 +1,79 @@ +import Domain +import SwiftUI + +struct TrashFolderCardView: View { + let isSelected: Bool + var select: SelectionMode + let folder: Folder + let action: ((Folder, Bool) -> Void)? + let completeAction: (() -> Void)? + + init( + select: SelectionMode = .none, + isSelected: Bool = false, + folder: Folder, + action: ((Folder, Bool) -> Void)? = nil, + completeAction: (() -> Void)? = nil + ) { + self.select = select + self.isSelected = isSelected + self.folder = folder + self.action = action + self.completeAction = completeAction + } + + var isEdit: Bool { + select != .none + } + + var body: some View { + HStack(spacing: 0) { + if isEdit { + checkIcon + } + cardContent + } + .editfolderCardStyle(isSelected: isSelected) + .onTapGesture { + if isEdit { + action?(folder, !isSelected) + } else { + completeAction?() + } + } + } + + private var checkIcon: some View { + VStack(alignment: .center, spacing: 0) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.gray950, .point600) + .overlay { + if !isSelected { + Circle() + .fill(.gray850) + } + } + } + .padding(.trailing) + } + + @ViewBuilder + private var cardContent: some View { + let time: String = Date.now.trashFolderText( + deletedAt: folder.deletedAt, + count: folder.voiceNoteIDs.count + ) + + HStack(spacing: 16) { + Image(systemName: "folder") + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 6) { + Text(folder.name) + .typography(.title2) + .foregroundStyle(.gray900) + Text(time) + .foregroundColor(.gray800) + } + } + } +} diff --git a/Presentation/Sources/Component/Trash/TrashVoiceNoteCardView.swift b/Presentation/Sources/Component/Trash/TrashVoiceNoteCardView.swift new file mode 100644 index 00000000..da66f279 --- /dev/null +++ b/Presentation/Sources/Component/Trash/TrashVoiceNoteCardView.swift @@ -0,0 +1,79 @@ +import Domain +import SwiftUI + +struct TrashVoiceNoteCardView: View { + let isSelected: Bool + var select: SelectionMode + let voiceNote: VoiceNote + let action: ((VoiceNote, Bool) -> Void)? + let completeAction: (() -> Void)? + + init( + select: SelectionMode = .none, + isSelected: Bool = false, + voiceNote: VoiceNote, + action: ((VoiceNote, Bool) -> Void)? = nil, + completeAction: (() -> Void)? = nil + ) { + self.select = select + self.isSelected = isSelected + self.voiceNote = voiceNote + self.action = action + self.completeAction = completeAction + } + + var isEdit: Bool { + select != .none + } + + var body: some View { + HStack(spacing: 0) { + if isEdit { + checkIcon + } + cardContent + } + .editfolderCardStyle(isSelected: isSelected) + .onTapGesture { + if isEdit { + action?(voiceNote, !isSelected) + } else { + completeAction?() + } + } + } + + private var checkIcon: some View { + VStack(alignment: .center, spacing: 0) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.gray950, .point600) + .overlay { + if !isSelected { + Circle() + .fill(.gray850) + } + } + } + .padding(.trailing) + } + + @ViewBuilder + private var cardContent: some View { + let time: String = Date.now.trashVoiceNoteDay( + createdAt: voiceNote.createdAt, + updatedAt: voiceNote.updatedAt, + deletedAt: voiceNote.deletedAt + ) + HStack(spacing: 16) { + Image(systemName: "microphone") + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 6) { + Text(voiceNote.title) + .typography(.title2) + .foregroundStyle(.gray900) + Text(time) + .foregroundColor(.gray800) + } + } + } +} diff --git a/Presentation/Sources/Component/VoiceNote/AudioPlayerView.swift b/Presentation/Sources/Component/VoiceNote/AudioPlayerView.swift new file mode 100644 index 00000000..6b36f7a2 --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/AudioPlayerView.swift @@ -0,0 +1,156 @@ +import Domain +import UIKit + +final class AudioPlayerView: UIView { + var onPlayPause: (() -> Void)? + var onRewind: (() -> Void)? + var onForward: (() -> Void)? + var onSeekBegan: (() -> Void)? + var onSeekEnded: ((TimeInterval) -> Void)? + + private let currentTimeLabel: UILabel = { + let label = UILabel() + label.setTypography(style: .label) + label.textColor = .gray750 + return label + }() + + private let totalDurationLabel: UILabel = { + let label = UILabel() + label.setTypography(style: .label) + label.textColor = .gray750 + + return label + }() + + private let rewindButton: UIButton = { + var config = UIButton.Configuration.plain() + config.image = .rewind + config.baseForegroundColor = .gray900 + + return UIButton(configuration: config) + }() + + private let playPauseButton: UIButton = { + var config = UIButton.Configuration.clearGlass() + config.image = .play + config.baseForegroundColor = .white + config.background.backgroundColor = .point700 + config.background.cornerRadius = 99 + + return UIButton(configuration: config) + }() + + private let forwardButton: UIButton = { + var config = UIButton.Configuration.plain() + config.image = .forward + config.baseForegroundColor = .gray900 + + return UIButton(configuration: config) + }() + + private let progressView = PlaybackProgressView() + + private lazy var durationStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.addArrangedSubview(currentTimeLabel) + stackView.addArrangedSubview(totalDurationLabel) + + return stackView + }() + + private lazy var buttonStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.spacing = 35 + stackView.addArrangedSubview(rewindButton) + stackView.addArrangedSubview(playPauseButton) + stackView.addArrangedSubview(forwardButton) + + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupActions() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + func apply(_ state: AudioPlaybackState) { + totalDurationLabel.text = state.duration.durationString + var config = playPauseButton.configuration + config?.image = state.status == .playing ? UIImage(systemName: "pause.fill") : UIImage(systemName: "play.fill") + playPauseButton.configuration = config + progressView.setDuration(state.duration) + progressView.setCurrentTime(state.currentTime) + if !progressView.isInteracting { + currentTimeLabel.text = state.currentTime.durationString + } + } + + private func setupUI() { + backgroundColor = .gray0 + + for view in [progressView, durationStackView, buttonStackView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + + NSLayoutConstraint.activate([ + rewindButton.widthAnchor.constraint(equalToConstant: 60), + rewindButton.heightAnchor.constraint(equalToConstant: 60), + + playPauseButton.widthAnchor.constraint(equalToConstant: 120), + playPauseButton.heightAnchor.constraint(equalToConstant: 60), + + forwardButton.widthAnchor.constraint(equalToConstant: 60), + forwardButton.heightAnchor.constraint(equalToConstant: 60), + + progressView.topAnchor.constraint(equalTo: topAnchor), + progressView.leadingAnchor.constraint(equalTo: leadingAnchor), + progressView.trailingAnchor.constraint(equalTo: trailingAnchor), + progressView.heightAnchor.constraint(equalToConstant: 8), + + durationStackView.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: 18), + durationStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + durationStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24), + + buttonStackView.topAnchor.constraint(equalTo: durationStackView.bottomAnchor, constant: 9), + buttonStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40), + buttonStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40), + buttonStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -46) + ]) + } + + private func setupActions() { + rewindButton.addAction(UIAction { [weak self] _ in self?.onRewind?() }, for: .touchUpInside) + playPauseButton.addAction(UIAction { [weak self] _ in self?.onPlayPause?() }, for: .touchUpInside) + forwardButton.addAction(UIAction { [weak self] _ in self?.onForward?() }, for: .touchUpInside) + + progressView.onSeekBegan = { [weak self] in self?.onSeekBegan?() } + progressView.onValueChanging = { [weak self] time in + self?.currentTimeLabel.text = time.durationString + } + progressView.onSeekEnded = { [weak self] time in self?.onSeekEnded?(time) } + } +} + +#Preview("정지") { + let view = AudioPlayerView() + view.apply(AudioPlaybackState(status: .idle, currentTime: 0, duration: 180)) + return view +} + +#Preview("재생 중") { + let view = AudioPlayerView() + view.apply(AudioPlaybackState(status: .playing, currentTime: 72, duration: 180)) + return view +} diff --git a/Presentation/Sources/Component/VoiceNote/KeywordChipLabel.swift b/Presentation/Sources/Component/VoiceNote/KeywordChipLabel.swift new file mode 100644 index 00000000..a3c03513 --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/KeywordChipLabel.swift @@ -0,0 +1,94 @@ +import UIKit + +public final class KeywordChipLabel: TypographyLabel { + /// 한 줄 텍스트 기준 KeywordChipLabel의 표준 높이. + /// (typography line height + vertical padding × 2) + public static var standardHeight: CGFloat { + Typography.label.font.pointSize * Typography.label.lineHeightRatio + + 2 * Constant.keywordChipVerticalPadding + } + + private let insets = UIEdgeInsets( + top: Constant.keywordChipVerticalPadding, + left: Constant.keywordChipHorizontalPadding, + bottom: Constant.keywordChipVerticalPadding, + right: Constant.keywordChipHorizontalPadding + ) + + private var baseText: String = "" + + public init(text: String) { + super.init(typography: .label) + textColor = UIColor.gray750 + backgroundColor = UIColor.gray100 + clipsToBounds = true + baseText = text + self.text = text + } + + /// 텍스트 내 `query`에 일치하는 모든 범위에 형광펜 스타일의 배경 하이라이트를 적용합니다. + /// `query`가 비어 있으면 기본 타이포그래피로 복원됩니다. + /// `focusedRange`가 지정되면 해당 범위는 `focusedHighlightBackgroundColor`로 덮어씌웁니다. + public func applyHighlight( + query: String, + highlightBackgroundColor: UIColor, + focusedRange: NSRange? = nil, + focusedHighlightBackgroundColor: UIColor? = nil + ) { + applyHighlight( + ranges: baseText.ranges(of: query), + highlightBackgroundColor: highlightBackgroundColor, + focusedRange: focusedRange, + focusedHighlightBackgroundColor: focusedHighlightBackgroundColor + ) + } + + /// 미리 계산된 범위에 형광펜 스타일의 배경 하이라이트를 적용합니다. + public func applyHighlight( + ranges: [NSRange], + highlightBackgroundColor: UIColor, + focusedRange: NSRange? = nil, + focusedHighlightBackgroundColor: UIColor? = nil + ) { + guard !ranges.isEmpty else { + text = baseText + return + } + attributedText = baseText.highlighted( + ranges: ranges, + baseAttributes: typography.textAttributes, + highlightBackgroundColor: highlightBackgroundColor, + focusedRange: focusedRange, + focusedHighlightBackgroundColor: focusedHighlightBackgroundColor + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override public func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + + override public func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect { + let insetBounds = bounds.inset(by: insets) + let textRect = super.textRect(forBounds: insetBounds, limitedToNumberOfLines: numberOfLines) + let invertedInsets = UIEdgeInsets( + top: -insets.top, left: -insets.left, + bottom: -insets.bottom, right: -insets.right + ) + + return textRect.inset(by: invertedInsets) + } + + override public func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } +} + +#Preview { + KeywordChipLabel(text: "키워드 칩") +} diff --git a/Presentation/Sources/Component/VoiceNote/PlaybackProgressView.swift b/Presentation/Sources/Component/VoiceNote/PlaybackProgressView.swift new file mode 100644 index 00000000..e302d5a5 --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/PlaybackProgressView.swift @@ -0,0 +1,150 @@ +import UIKit + +final class PlaybackProgressView: UIView { + var onSeekBegan: (() -> Void)? + var onValueChanging: ((TimeInterval) -> Void)? + var onSeekEnded: ((TimeInterval) -> Void)? + + private(set) var isInteracting = false + + private var duration: TimeInterval = 0 + private var currentTime: TimeInterval = 0 + + private let trackFill = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + func setDuration(_ duration: TimeInterval) { + guard self.duration != duration else { return } + self.duration = duration + setNeedsLayout() + } + + func setCurrentTime(_ time: TimeInterval) { + guard !isInteracting else { return } + currentTime = max(0, min(duration, time)) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + let ratio = duration > 0 ? CGFloat(currentTime / duration) : 0 + let clamped = max(0, min(1, ratio)) + trackFill.frame = CGRect(x: 0, y: 0, width: bounds.width * clamped, height: bounds.height) + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: 0, dy: -11).contains(point) + } + + private func setup() { + backgroundColor = .gray200 + + trackFill.backgroundColor = .point700 + addSubview(trackFill) + + let press = UILongPressGestureRecognizer(target: self, action: #selector(handlePress(_:))) + press.minimumPressDuration = 0 + addGestureRecognizer(press) + } + + private func time(atX x: CGFloat) -> TimeInterval { + guard bounds.width > 0, duration > 0 else { return 0 } + let ratio = max(0, min(1, x / bounds.width)) + return Double(ratio) * duration + } + + @objc + private func handlePress(_ recognizer: UILongPressGestureRecognizer) { + guard duration > 0 else { return } + let t = time(atX: recognizer.location(in: self).x) + + switch recognizer.state { + case .began: + isInteracting = true + currentTime = t + setNeedsLayout() + onSeekBegan?() + onValueChanging?(t) + case .changed: + currentTime = t + setNeedsLayout() + onValueChanging?(t) + case .ended, .cancelled, .failed: + currentTime = t + setNeedsLayout() + isInteracting = false + onSeekEnded?(t) + default: + break + } + } +} + +#Preview("시작") { + let container = UIView() + container.backgroundColor = .gray0 + + let view = PlaybackProgressView() + view.setDuration(100) + view.setCurrentTime(0) + view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24), + view.centerYAnchor.constraint(equalTo: container.centerYAnchor), + view.heightAnchor.constraint(equalToConstant: 8) + ]) + + return container +} + +#Preview("중간") { + let container = UIView() + container.backgroundColor = .gray0 + + let view = PlaybackProgressView() + view.setDuration(100) + view.setCurrentTime(40) + view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24), + view.centerYAnchor.constraint(equalTo: container.centerYAnchor), + view.heightAnchor.constraint(equalToConstant: 8) + ]) + + return container +} + +#Preview("끝") { + let container = UIView() + container.backgroundColor = .gray0 + + let view = PlaybackProgressView() + view.setDuration(100) + view.setCurrentTime(100) + view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24), + view.centerYAnchor.constraint(equalTo: container.centerYAnchor), + view.heightAnchor.constraint(equalToConstant: 8) + ]) + + return container +} diff --git a/Presentation/Sources/Component/VoiceNote/RegenerationChip.swift b/Presentation/Sources/Component/VoiceNote/RegenerationChip.swift new file mode 100644 index 00000000..c1df0fa1 --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/RegenerationChip.swift @@ -0,0 +1,132 @@ +import UIKit + +final class RegenerationChip: UIView { + enum State { + case idle + case loading + case outdated + } + + private let iconView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = UIColor.gray775 + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let label: TypographyLabel = { + let label = TypographyLabel(typography: .label) + label.textColor = UIColor.gray775 + return label + }() + + private let indicatorDot: UIView = { + let view = UIView() + view.backgroundColor = UIColor.warning + view.layer.cornerRadius = Constant.chipIndicatorSize / 2 + return view + }() + + private let stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = Constant.chipContentSpacing + return stack + }() + + init(state: State = .idle) { + super.init(frame: .zero) + setupUI() + apply(state: state) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + + func apply(state: State) { + iconView.isHidden = !state.showsIcon + indicatorDot.isHidden = !state.showsIndicator + label.text = state.text + isUserInteractionEnabled = state.isInteractive + } + + private func setupUI() { + backgroundColor = UIColor.point150 + layer.borderColor = UIColor.point600.cgColor + layer.borderWidth = Constant.borderWidth + clipsToBounds = true + + iconView.image = UIImage(systemName: "arrow.clockwise") + + for subview in [stackView, iconView, label, indicatorDot] { + subview.translatesAutoresizingMaskIntoConstraints = false + } + + addSubview(stackView) + stackView.addArrangedSubview(iconView) + stackView.addArrangedSubview(label) + stackView.addArrangedSubview(indicatorDot) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: Constant.chipVerticalPadding), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constant.chipHorizontalPadding), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constant.chipHorizontalPadding), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constant.chipVerticalPadding), + iconView.widthAnchor.constraint(equalToConstant: Constant.chipIconSize), + iconView.heightAnchor.constraint(equalToConstant: Constant.chipIconSize), + indicatorDot.widthAnchor.constraint(equalToConstant: Constant.chipIndicatorSize), + indicatorDot.heightAnchor.constraint(equalToConstant: Constant.chipIndicatorSize), + heightAnchor.constraint(greaterThanOrEqualToConstant: Constant.chipMinimumHeight) + ]) + } +} + +private extension RegenerationChip.State { + var text: String { + switch self { + case .idle, .outdated: return "재생성" + case .loading: return "재생성 중..." + } + } + + var showsIcon: Bool { + switch self { + case .idle, .outdated: return true + case .loading: return false + } + } + + var showsIndicator: Bool { + switch self { + case .outdated: return true + case .idle, .loading: return false + } + } + + var isInteractive: Bool { + switch self { + case .idle, .outdated: return true + case .loading: return false + } + } +} + +#Preview("idle") { + RegenerationChip(state: .idle) +} + +#Preview("loading") { + RegenerationChip(state: .loading) +} + +#Preview("outdated") { + RegenerationChip(state: .outdated) +} diff --git a/Presentation/Sources/Component/VoiceNote/SearchVoiceNoteCardView.swift b/Presentation/Sources/Component/VoiceNote/SearchVoiceNoteCardView.swift new file mode 100644 index 00000000..63400fc0 --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/SearchVoiceNoteCardView.swift @@ -0,0 +1,43 @@ +import Domain +import SwiftUI + +struct SearchVoiceNoteCardView: View { + let title: String + let keyword: String + let timeline: String + let action: () -> Void + + var body: some View { + HStack(spacing: 16) { + Image(systemName: "microphone") + .frame(maxWidth: 20, maxHeight: 20) + .foregroundStyle(.gray750) + VStack(alignment: .leading, spacing: 6) { + Text(fullText: title, keyword: keyword) + .typography(.title3) + .foregroundStyle(.gray950) + Text(timeline) + .typography(.label) + .foregroundStyle(.gray750) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .glassEffect( + .clear.tint(.point200.opacity(0.2)), + in: .rect(cornerRadius: Constant.cornerRadius) + ) + .contentShape(.rect(cornerRadius: Constant.cornerRadius)) + .onTapGesture { action() } + } +} + +#Preview { + SearchVoiceNoteCardView( + title: "녹음을 요약한 기록 제목 가을", + keyword: "녹음", + timeline: "오후 4:30 * 1시간 54분", + action: {} + ) + .padding() +} diff --git a/Presentation/Sources/Component/VoiceNote/SkeletonLineView.swift b/Presentation/Sources/Component/VoiceNote/SkeletonLineView.swift new file mode 100644 index 00000000..f5068760 --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/SkeletonLineView.swift @@ -0,0 +1,79 @@ +import UIKit + +final class SkeletonLineView: UIView { + private static let animationKey = "skeleton.scaleX" + + private let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + layer.addSublayer(gradientLayer) + gradientLayer.startPoint = .init(x: 0, y: 0.5) + gradientLayer.endPoint = .init(x: 1, y: 0.5) + gradientLayer.cornerRadius = Constant.skeletonLineHeight / 2 + gradientLayer.colors = [ + UIColor.gray400.cgColor, + UIColor.gray900.withAlphaComponent(Constant.skeletonLineTrailingAlpha).cgColor + ] + } + + required init?(coder: NSCoder) { + nil + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: Constant.skeletonLineHeight) + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.anchorPoint = .init(x: 0, y: 0.5) + gradientLayer.bounds = CGRect(origin: .zero, size: bounds.size) + gradientLayer.position = CGPoint(x: 0, y: bounds.midY) + } + + func startAnimating(beginOffset: CFTimeInterval = 0) { + let animation = CABasicAnimation(keyPath: "transform.scale.x") + animation.fromValue = Constant.skeletonScaleFrom + animation.toValue = Constant.skeletonScaleTo + animation.duration = Constant.skeletonAnimationDuration + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.autoreverses = true + animation.repeatCount = .infinity + animation.beginTime = CACurrentMediaTime() + beginOffset + animation.fillMode = .both + gradientLayer.add(animation, forKey: Self.animationKey) + } + + func stopAnimating() { + gradientLayer.removeAnimation(forKey: Self.animationKey) + } +} + +#Preview { + let container = UIView() + container.backgroundColor = UIColor.gray100 + + let widths: [CGFloat] = [204, 173, 225] + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 6 + stack.alignment = .leading + stack.translatesAutoresizingMaskIntoConstraints = false + + for (index, w) in widths.enumerated() { + let line = SkeletonLineView() + line.translatesAutoresizingMaskIntoConstraints = false + line.widthAnchor.constraint(equalToConstant: w).isActive = true + line.startAnimating(beginOffset: Double(index) * 0.2) + stack.addArrangedSubview(line) + } + + container.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: container.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + return container +} diff --git a/Presentation/Sources/Component/VoiceNote/VoiceNoteBottomFadeView.swift b/Presentation/Sources/Component/VoiceNote/VoiceNoteBottomFadeView.swift new file mode 100644 index 00000000..84e62c2c --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/VoiceNoteBottomFadeView.swift @@ -0,0 +1,59 @@ +import UIKit + +final class VoiceNoteBottomFadeView: UIView { + private let gradientLayer = CAGradientLayer() + + init() { + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + isUserInteractionEnabled = false + backgroundColor = .clear + + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + gradientLayer.colors = [ + UIColor.gray0.withAlphaComponent(0).cgColor, + UIColor.gray0.cgColor + ] + layer.addSublayer(gradientLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} + +#Preview { + let container = UIView() + container.backgroundColor = .gray0 + + let content = UILabel() + content.text = Array(repeating: "컨텐츠 컨텐츠 컨텐츠 컨텐츠 컨텐츠", count: 8).joined(separator: "\n") + content.numberOfLines = 0 + content.textColor = .gray950 + content.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(content) + + let fade = VoiceNoteBottomFadeView() + container.addSubview(fade) + + NSLayoutConstraint.activate([ + content.topAnchor.constraint(equalTo: container.topAnchor, constant: 20), + content.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), + content.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + + fade.leadingAnchor.constraint(equalTo: container.leadingAnchor), + fade.trailingAnchor.constraint(equalTo: container.trailingAnchor), + fade.bottomAnchor.constraint(equalTo: container.bottomAnchor), + fade.heightAnchor.constraint(equalToConstant: 80) + ]) + + return container +} diff --git a/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift b/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift new file mode 100644 index 00000000..f036a7cf --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/VoiceNoteCardView.swift @@ -0,0 +1,168 @@ +import Domain +import SwiftUI + +struct VoiceNoteCardView: View { + let isSelected: Bool + var select: SelectionMode + let voiceNote: VoiceNote + let action: ((VoiceNote, Bool) -> Void)? + let completeAction: (() -> Void)? + init( + select: SelectionMode = .none, + isSelected: Bool = false, + voiceNote: VoiceNote, + action: ((VoiceNote, Bool) -> Void)? = nil, + completeAction: (() -> Void)? = nil + ) { + self.select = select + self.isSelected = isSelected + self.voiceNote = voiceNote + self.action = action + self.completeAction = completeAction + } + + var isEdit: Bool { + select != .none + } + + var body: some View { + HStack(spacing: 0) { + if isEdit { + checkIcon + } + VStack(alignment: .leading, spacing: 6) { + cardContent + } + } + .editVoiceNoteCardStyle(isSelected: isSelected) + .onTapGesture { + if isEdit { + action?(voiceNote, !isSelected) + } else { + completeAction?() + } + } + } + + private var checkIcon: some View { + VStack(alignment: .center, spacing: 0) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.gray950, .point600) + .overlay { + if !isSelected { + Circle() + .fill(.gray850) + } + } + } + .padding(.trailing) + } + + @ViewBuilder + private var cardContent: some View { + let time: String = Date.now.voiceNoteDay( + createdAt: voiceNote.createdAt, + updatedAt: voiceNote.updatedAt, + duration: voiceNote.voiceRecord.duration + ) + Group { + Text(voiceNote.title) + .typography(.title2) + .foregroundStyle(.gray950) + Text(time) + .typography(.body2) + .foregroundStyle(.gray800) + analysisText(binding: voiceNote.analysisState.bindingValue) + } + } +} + +// MARK: - Helper + +extension VoiceNoteCardView { + /// 분석 상태에 따른 텍스트 뷰 (요약 중, 요약 실패, 요약 완료 + @ViewBuilder + func analysisText(binding: AnalysisState.BindingKey) -> some View { + var currentText: String { + switch binding { + case .progress: + "요약 중" + case .success: + "요약 성공" + case .failed: + "요약 실패" + } + } + + switch binding { + case .progress, .failed: + Text(currentText) + .typography(.label) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .overlay( + Capsule() + .stroke(Color.gray500, lineWidth: 1) + ) + .background(.gray200, in: .capsule) + .foregroundStyle(.gray750) + case .success: + Text(currentText) + .typography(.label) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .overlay( + Capsule() + .stroke(Color.point600, lineWidth: 1) + ) + .background(.point600.opacity(0.2), in: .capsule) + .foregroundStyle(.gray750) + } + } +} + +/// ViewModifier 확장 +extension View { + func editVoiceNoteCardStyle(isSelected: Bool) -> some View { + modifier( + EditVoiceNoteCardModifier( + isSelected: isSelected + ) + ) + } +} + +/// 음성 노트 선택 모드 스타일 정의 +struct EditVoiceNoteCardModifier: ViewModifier { + let isSelected: Bool + private let cornerRadius: CGFloat = 20 + + private var borderColor: Color { + isSelected ? .point900 : .gray500 + } + + func body(content: Content) -> some View { + content + .frame(minHeight: 118) + .frame(maxWidth: .infinity, maxHeight: 120, alignment: .leading) + .padding(.horizontal) + .glassEffect(.clear.tint(.point200.opacity(0.2)), in: .rect(cornerRadius: 20)) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 20) + .stroke(.point900, lineWidth: 1) + } + } + .contentShape(.rect(cornerRadius: 20)) + } +} + +// #Preview { +// VoiceNoteCardView( +// select: .none, +// voiceNote: . +// action: nil +// ) +// .padding() +// .background(.gray50) +// } diff --git a/Presentation/Sources/Component/VoiceNote/VoiceNoteMatchAccessoryBar.swift b/Presentation/Sources/Component/VoiceNote/VoiceNoteMatchAccessoryBar.swift new file mode 100644 index 00000000..1121a99f --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/VoiceNoteMatchAccessoryBar.swift @@ -0,0 +1,88 @@ +import UIKit + +public final class VoiceNoteMatchAccessoryBar: UIVisualEffectView { + public var onPrev: (() -> Void)? + public var onNext: (() -> Void)? + + private let countLabel: TypographyLabel = { + let label = TypographyLabel(typography: .title3) + label.textColor = .gray950 + label.textAlignment = .center + return label + }() + + private let prevButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(.chevronUp, for: .normal) + button.tintColor = .gray950 + return button + }() + + private let nextButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(.chevronDown, for: .normal) + button.tintColor = .gray950 + return button + }() + + private lazy var stackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [prevButton, countLabel, nextButton]) + stack.axis = .horizontal + stack.distribution = .equalSpacing + stack.alignment = .top + return stack + }() + + public init() { + let effect = UIGlassEffect(style: .regular) + effect.tintColor = .gray200.withAlphaComponent(0.2) + super.init(effect: effect) + clipsToBounds = true + layer.cornerRadius = 20 + setupUI() + setupActions() + configure(countText: "0 / 0", hasMatches: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + public func configure(countText: String, hasMatches: Bool) { + countLabel.text = countText + prevButton.isEnabled = hasMatches + nextButton.isEnabled = hasMatches + prevButton.tintColor = hasMatches ? .gray950 : .gray600 + nextButton.tintColor = hasMatches ? .gray950 : .gray600 + } + + private func setupUI() { + for button in [prevButton, nextButton] { + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 24), + button.heightAnchor.constraint(equalToConstant: 24) + ]) + } + + stackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12) + ]) + } + + private func setupActions() { + prevButton.addAction(UIAction { [weak self] _ in + self?.onPrev?() + }, for: .touchUpInside) + + nextButton.addAction(UIAction { [weak self] _ in + self?.onNext?() + }, for: .touchUpInside) + } +} diff --git a/Presentation/Sources/Component/VoiceNote/VoiceNoteNavigationBar.swift b/Presentation/Sources/Component/VoiceNote/VoiceNoteNavigationBar.swift new file mode 100644 index 00000000..eca496b2 --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/VoiceNoteNavigationBar.swift @@ -0,0 +1,324 @@ +import UIKit + +@MainActor +final class VoiceNoteNavigationBar { + typealias EditingMode = VoiceNoteViewModel.EditingMode + + // MARK: - Events + + var onBack: (() -> Void)? + var onEditCancel: (() -> Void)? + var onDoneTitle: ((String) -> Void)? + var onDoneScript: (() -> Void)? + var onMove: (() -> Void)? + var onEditScript: (() -> Void)? + var onDelete: (() -> Void)? + var onTapTitle: (() -> Void)? + var onSearchEnter: (() -> Void)? + var onSearchQuery: ((String) -> Void)? + var onSearchClose: (() -> Void)? + + // MARK: - UI Components + + private let titleContainerView = TitleContainerView() + private let searchBar = VoiceNoteSearchBar() + + private let backItem = UIBarButtonItem(image: .chevronLeft) + private let editCancelItem = UIBarButtonItem(image: .cornerUpLeft) + private let doneItem = UIBarButtonItem(title: "완료") + private let moreItem = UIBarButtonItem(image: .moreVertical) + private let searchItem = UIBarButtonItem(image: .search) + + // MARK: - State + + private var currentEditingMode: EditingMode? + private var lastSearchMode = false + + var titleText: String { + get { titleContainerView.text ?? "" } + set { titleContainerView.text = newValue } + } + + var isEditingTitle: Bool { + titleContainerView.isEditingTitle + } + + // MARK: - Init + + init() { + setupBarItems() + setupTitleContainer() + setupSearchBar() + } + + // MARK: - Public API + + /// 네비게이션 바 상태를 적용한다. + /// - Returns: 검색 모드가 전환(진입/해제)되었으면 `true` + @discardableResult + func apply( + to navigationItem: UINavigationItem, + title: String, + editingMode: EditingMode? = nil, + searchMode: Bool = false, + hasScriptEdits: Bool = false, + isTrashMode: Bool = false + ) -> Bool { + currentEditingMode = editingMode + let didToggleSearch = searchMode != lastSearchMode + lastSearchMode = searchMode + + titleContainerView.isUserInteractionEnabled = !isTrashMode + + switch editingMode { + case .title: + if !titleContainerView.isEditingTitle { + titleContainerView.text = title + titleContainerView.setEditing(true) + } + case .script: + titleContainerView.isHidden = true + editCancelItem.tintColor = hasScriptEdits ? UIColor.gray950 : UIColor.gray600 + case nil: + titleContainerView.setEditing(false) + titleContainerView.text = title + titleContainerView.isHidden = false + } + + if searchMode { + navigationItem.hidesBackButton = true + navigationItem.leftBarButtonItem = nil + navigationItem.rightBarButtonItems = [] + navigationItem.titleView = searchBar + } else { + navigationItem.hidesBackButton = false + navigationItem.titleView = titleContainerView + switch editingMode { + case .title: + navigationItem.leftBarButtonItem = backItem + navigationItem.rightBarButtonItems = [doneItem] + case .script: + navigationItem.leftBarButtonItem = editCancelItem + navigationItem.rightBarButtonItems = [doneItem] + case nil: + navigationItem.leftBarButtonItem = backItem + if isTrashMode { + navigationItem.rightBarButtonItems = [searchItem] + } else { + navigationItem.rightBarButtonItems = [moreItem, searchItem] + } + } + } + + if didToggleSearch { + if searchMode { + searchBar.becomeFirstResponder() + } else { + searchBar.setQuery("") + searchBar.resignFirstResponder() + } + } + + return didToggleSearch + } +} + +// MARK: - Setup + +private extension VoiceNoteNavigationBar { + func setupBarItems() { + for item in [backItem, editCancelItem, doneItem, moreItem, searchItem] { + item.hidesSharedBackground = true + } + backItem.tintColor = UIColor.gray950 + doneItem.tintColor = UIColor.point800 + [moreItem, searchItem].forEach { $0.tintColor = .white } + + backItem.primaryAction = UIAction { [weak self] _ in + self?.onBack?() + } + editCancelItem.primaryAction = UIAction { [weak self] _ in + self?.onEditCancel?() + } + doneItem.primaryAction = UIAction { [weak self] _ in + guard let self else { return } + switch currentEditingMode { + case .title: onDoneTitle?(titleContainerView.text ?? "") + case .script: onDoneScript?() + case nil: break + } + } + moreItem.menu = UIMenu(children: [ + UIAction(title: "기록 이동하기") { [weak self] _ in + self?.onMove?() + }, + UIAction(title: "편집하기") { [weak self] _ in + self?.onEditScript?() + }, + UIAction(title: "삭제하기", attributes: .destructive) { [weak self] _ in + self?.onDelete?() + } + ]) + searchItem.primaryAction = UIAction { [weak self] _ in + self?.onSearchEnter?() + } + } + + func setupTitleContainer() { + titleContainerView.onTapTitle = { [weak self] in + self?.onTapTitle?() + } + titleContainerView.onCommit = { [weak self] text in + self?.onDoneTitle?(text) + } + } + + func setupSearchBar() { + searchBar.onReturn = { [weak self] query in + self?.onSearchQuery?(query) + } + searchBar.onClose = { [weak self] in + self?.onSearchClose?() + } + } +} + +// MARK: - TitleContainerView + +/// 네비게이션 바의 titleView로 사용하기 위한 컨테이너 뷰. +/// +/// intrinsicContentSize의 width를 최대로 반환하여 +/// 좌측 ~ 우측 바 아이템 사이의 가용 영역을 전부 차지한다. +/// 표시용 Label과 편집용 TextField를 함께 소유하며, +/// `setEditing(_:)`으로 두 상태를 전환한다. +private final class TitleContainerView: UIView { + var onTapTitle: (() -> Void)? + var onCommit: ((String) -> Void)? + + private let titleLabel: TypographyLabel = { + let label = TypographyLabel(typography: .header2) + label.textColor = UIColor.gray950 + label.lineBreakMode = .byTruncatingTail + label.numberOfLines = 1 + label.isUserInteractionEnabled = true + return label + }() + + private let titleField: TypographyTextField = { + let field = TypographyTextField(typography: .header2) + field.textColor = UIColor.gray950 + field.tintColor = UIColor.gray950 + field.returnKeyType = .done + field.isHidden = true + return field + }() + + /// setEditing으로 진입한 편집 상태 여부. + /// resignFirstResponder가 편집 종료 콜백을 재발화시키는 것을 막기 위해 사용한다. + private(set) var isEditingTitle = false + + var text: String? { + get { titleField.text } + set { + titleLabel.text = newValue + titleField.text = newValue + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override var intrinsicContentSize: CGSize { + let height = subviews.first(where: { !$0.isHidden })?.intrinsicContentSize.height + ?? super.intrinsicContentSize.height + return CGSize(width: UIView.layoutFittingExpandedSize.width, height: height) + } + + func setEditing(_ isEditing: Bool) { + isEditingTitle = isEditing + if isEditing { + titleLabel.isHidden = true + titleField.isHidden = false + titleField.becomeFirstResponder() + } else { + titleField.resignFirstResponder() + titleLabel.isHidden = false + titleField.isHidden = true + } + } + + private func setup() { + titleField.delegate = self + + let tap = UITapGestureRecognizer(target: self, action: #selector(titleTapped)) + titleLabel.addGestureRecognizer(tap) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleField.translatesAutoresizingMaskIntoConstraints = false + + addSubview(titleLabel) + addSubview(titleField) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + titleLabel.topAnchor.constraint(equalTo: topAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + + titleField.leadingAnchor.constraint(equalTo: leadingAnchor), + titleField.trailingAnchor.constraint(equalTo: trailingAnchor), + titleField.topAnchor.constraint(equalTo: topAnchor), + titleField.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + @objc + private func titleTapped() { + onTapTitle?() + } +} + +// MARK: - TitleContainerView + UITextFieldDelegate + +extension TitleContainerView: UITextFieldDelegate { + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + onTapTitle?() + return true + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + onCommit?(textField.text ?? "") + return true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + guard isEditingTitle else { return } + onCommit?(textField.text ?? "") + } +} + +// MARK: - Preview + +#Preview { + let viewController = ViewController() + viewController.loadViewIfNeeded() + + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundEffect = UIBlurEffect(style: .dark) + viewController.navigationItem.standardAppearance = appearance + viewController.navigationItem.compactAppearance = appearance + viewController.navigationItem.scrollEdgeAppearance = appearance + + let navigationBar = VoiceNoteNavigationBar() + navigationBar.apply(to: viewController.navigationItem, title: "음성 메모 제목") + + return UINavigationController(rootViewController: viewController) +} diff --git a/Presentation/Sources/Component/VoiceNote/VoiceNoteSearchBar.swift b/Presentation/Sources/Component/VoiceNote/VoiceNoteSearchBar.swift new file mode 100644 index 00000000..f05828ec --- /dev/null +++ b/Presentation/Sources/Component/VoiceNote/VoiceNoteSearchBar.swift @@ -0,0 +1,150 @@ +import UIKit + +public final class VoiceNoteSearchBar: UIView { + public var onClose: (() -> Void)? + public var onReturn: ((String) -> Void)? + + private let searchContainer: UIVisualEffectView = { + let effect = UIGlassEffect(style: .clear) + effect.tintColor = .point100.withAlphaComponent(0.2) + UIView.animate { + effect.isInteractive = true + } + let view = UIVisualEffectView(effect: effect) + view.layer.cornerRadius = 20 + view.layer.borderWidth = 1 + view.layer.borderColor = UIColor.gray600.cgColor + return view + }() + + private let iconView: UIImageView = { + let imageView = UIImageView(image: .search) + imageView.tintColor = .gray850 + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let textField: TypographyTextField = { + let field = TypographyTextField(typography: .body1) + field.textColor = .white + field.tintColor = .white + field.returnKeyType = .search + field.clearButtonMode = .never + field.autocorrectionType = .no + field.autocapitalizationType = .none + field.spellCheckingType = .no + var placeholderAttrs = Typography.body1.textAttributes + placeholderAttrs[.foregroundColor] = UIColor.gray950 + field.attributedPlaceholder = NSAttributedString(string: "검색", attributes: placeholderAttrs) + return field + }() + + private let closeButton: UIButton = { + var config = UIButton.Configuration.prominentClearGlass() + config.image = UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 12)) + config.baseForegroundColor = .white + config.baseBackgroundColor = .point100.withAlphaComponent(0.2) + config.contentInsets = .zero + return UIButton(configuration: config) + }() + + // MARK: - Init + + public init() { + super.init(frame: .zero) + setupUI() + setupActions() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override public var intrinsicContentSize: CGSize { + CGSize(width: UIView.layoutFittingExpandedSize.width, height: 46) + } + + @discardableResult + override public func becomeFirstResponder() -> Bool { + textField.becomeFirstResponder() + } + + @discardableResult + override public func resignFirstResponder() -> Bool { + textField.resignFirstResponder() + } + + public func setQuery(_ query: String) { + textField.text = query + } + + // MARK: - Setup + + private func setupUI() { + addSubview(searchContainer) + addSubview(closeButton) + searchContainer.contentView.addSubview(iconView) + searchContainer.contentView.addSubview(textField) + + setupConstraints() + } + + private func setupConstraints() { + for subview in [searchContainer, closeButton, iconView, textField] { + subview.translatesAutoresizingMaskIntoConstraints = false + } + + let heightConstraint = heightAnchor.constraint(equalToConstant: 46) + heightConstraint.priority = .init(999) + + NSLayoutConstraint.activate([ + heightConstraint, + + searchContainer.topAnchor.constraint(equalTo: topAnchor), + searchContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + searchContainer.bottomAnchor.constraint(equalTo: bottomAnchor), + searchContainer.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -12), + + closeButton.topAnchor.constraint(equalTo: topAnchor), + closeButton.trailingAnchor.constraint(equalTo: trailingAnchor), + closeButton.widthAnchor.constraint(equalToConstant: 46), + closeButton.heightAnchor.constraint(equalToConstant: 46), + + iconView.leadingAnchor.constraint(equalTo: searchContainer.contentView.leadingAnchor, constant: 16), + iconView.centerYAnchor.constraint(equalTo: searchContainer.contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 20), + iconView.heightAnchor.constraint(equalToConstant: 20), + + textField.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), + textField.trailingAnchor.constraint(equalTo: searchContainer.contentView.trailingAnchor, constant: -16), + textField.topAnchor.constraint(equalTo: searchContainer.contentView.topAnchor), + textField.bottomAnchor.constraint(equalTo: searchContainer.contentView.bottomAnchor) + ]) + } + + private func setupActions() { + textField.addAction(UIAction { [weak self] _ in + guard let self else { return } + onReturn?(textField.text ?? "") + }, for: .editingDidEndOnExit) + + closeButton.addAction(UIAction { [weak self] _ in + self?.onClose?() + }, for: .touchUpInside) + } +} + +#Preview { + let vc = UIViewController() + vc.view.backgroundColor = .gray50 + let bar = VoiceNoteSearchBar() + bar.translatesAutoresizingMaskIntoConstraints = false + vc.view.addSubview(bar) + NSLayoutConstraint.activate([ + bar.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor, constant: 16), + bar.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor, constant: -16), + bar.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor) + ]) + return vc +} diff --git a/Presentation/Sources/DesignSystem/Alertable.swift b/Presentation/Sources/DesignSystem/Alertable.swift new file mode 100644 index 00000000..d92983a4 --- /dev/null +++ b/Presentation/Sources/DesignSystem/Alertable.swift @@ -0,0 +1,22 @@ +import UIKit + +@MainActor +public protocol Alertable: UIViewController { + func showAlert(message: String) +} + +public extension Alertable { + func showAlert(message: String) { + guard presentedViewController == nil else { return } + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + + func showAlert(title: String, message: String, onDismiss: @escaping () -> Void) { + guard presentedViewController == nil else { return } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default) { _ in onDismiss() }) + present(alert, animated: true) + } +} diff --git a/Presentation/Sources/DesignSystem/Constant.swift b/Presentation/Sources/DesignSystem/Constant.swift new file mode 100644 index 00000000..42f2898e --- /dev/null +++ b/Presentation/Sources/DesignSystem/Constant.swift @@ -0,0 +1,342 @@ +import Foundation + +// MARK: - General UI Constants + +public enum Constant { + /// 차곡 기본 Corner Radius 상수 값 + static let cornerRadius: CGFloat = 20 + + /// 차곡 공통 Capsule Corner Radius 상수 값 + static let capsuleCornerRadius: CGFloat = 99 + + /// View Background Opacity alpha + static let backgroundOpacity: CGFloat = 0.2 + + /// GlassButton Border Width 상수 값 (앱 전반 테두리로 사용 시) + static let borderWidth: CGFloat = 1.0 + + /// Animation Duration 값 + static let animationDuration: CGFloat = 0.15 + + /// 공용 버튼 높이 상수 값 (46) + static let commonButtonHeight: CGFloat = 54 +} + +// MARK: - GlassButton Constants + +public extension Constant { + /// GlassButton Shadow 상수 값 + static let shadowOpacity: Float = 0.16 + + /// GlassButton Shadow Offset ( width, height ) + static let shadowOffsetWidth: CGFloat = 2 + static let shadowOffsetHeight: CGFloat = 2 + + /// GlassButton Floating Size + static let floatingButtonSize: CGFloat = 64 +} + +// MARK: - AlertView Constants + +public extension Constant { + /// AlertView Spacing Top 상수 값 + static let alertTopContentSpacing: CGFloat = 12 + + /// AlertView Spacing Botttom 상수 값 + static let alertBottomContentSpacing: CGFloat = 8 + + /// AlertView( TopContent ) Top And Bottom 제약조건 상수 값 + static let alertTopAndBottomValueForTopContent: CGFloat = 32 + + /// AlertView( TopContent ) Leading And Trailing 제약조건 상수 값 + static let alertLeftAndRightValueForTopContent: CGFloat = 40 + + /// AlertView( BottomContent ) Top And Bottom 제약조건 상수 값 + static let alertTopAndBottomValueForBottomContent: CGFloat = 20 + + /// AlertView( BottomContent ) Leading And Trailing 제약조건 상수 값 + static let alertLeftAndRightValueForBottomContent: CGFloat = 20 + + /// AlertView TopContent와 BottomContent의 Spacing 상수 값 + static let alertTopAndBottomContentSpacing: CGFloat = 24 + + /// AlertView BottomContent Height 상수 값 + static let alertBottomContentHeight: CGFloat = 46 + + /// AlertView Width multiplier 비율 값 + static let alertMultiplierWidth: CGFloat = 0.8 + + /// AlertView Height multiplier 비율 값 + static let alertMultiplierHeight: CGFloat = 0.3 +} + +// MARK: - Pagenation Constants + +public extension Constant { + /// Pagenation 이동 Count 상수 값 + static let pagenationMoveCount: Int = 1 + + /// Pagenation 높이 상수 값 + static let pagenationHeight: CGFloat = 2 + + /// Pagenation Spacing 값 + static let pagenationSpacing: CGFloat = 4 + + /// Pagenation Total Count 값 + static let pagenationTotalValue: Int = 4 +} + +// MARK: - OnBoarding Constants + +public extension Constant { + /// OnBoarding 카드 뷰 공통 수평 패딩 (20) + static let onBoardingHorizontalPadding: CGFloat = 20 + + /// OnBoarding 버튼 공통 수평 패딩 (16) + static let onBoardingButtonHorizontalPadding: CGFloat = 16 + + /// OnBoarding 컨텐츠 요소 간 간격 (32) + static let onBoardingContentSpacing: CGFloat = 32 + + /// OnBoarding 페이지네이션과 페이징뷰 사이의 상단 여백 (105) + static let onBoardingPagingViewTopMargin: CGFloat = 105 + + /// OnBoarding 페이징뷰와 버튼 사이의 하단 여백 (16) + static let onBoardingPagingViewBottomMargin: CGFloat = 16 + + /// OnBoarding 버튼 간 상하 간격 (8) + static let onBoardingButtonSpacing: CGFloat = 8 + + /// OnBoardingCardView 바디 라벨과 이미지 사이의 특수 간격 (36) + static let onBoardingCardImageTopSpacing: CGFloat = 36 + + /// OnBoarding 라벨 최대 줄 수 (0) + static let onBoardingLabelNumberOfLines: Int = 0 + + /// OnBoarding 페이지네이션 상단 여백 (52) + static let onBoardingPaginationTopMargin: CGFloat = 52 +} + +// MARK: - KeywordChipLabel Constants + +public extension Constant { + /// KeywordChipLabel 수평 패딩 (12) + static let keywordChipHorizontalPadding: CGFloat = 12 + + /// KeywordChipLabel 수직 패딩 (8) + static let keywordChipVerticalPadding: CGFloat = 8 + + /// 키워드 칩 가로 간격 (10) + static let keywordChipInterItemSpacing: CGFloat = 10 + + /// 키워드 칩 세로 간격 (10) + static let keywordChipLineSpacing: CGFloat = 10 +} + +// MARK: - ChipView Constants + +public extension Constant { + /// ChipView 내부 수평 패딩 (12) + static let chipHorizontalPadding: CGFloat = 12 + + /// ChipView 내부 수직 패딩 (4) + static let chipVerticalPadding: CGFloat = 4 + + /// ChipView 아이콘과 텍스트 사이 간격 (8) + static let chipContentSpacing: CGFloat = 8 + + /// ChipView 아이콘 크기 (16) + static let chipIconSize: CGFloat = 16 + + /// ChipView 최소 높이 (28) + static let chipMinimumHeight: CGFloat = 28 + + /// ChipView 보조 표시 dot 크기 (6) + static let chipIndicatorSize: CGFloat = 6 +} + +// MARK: - LanguagePicker Constants + +public extension Constant { + /// LanguagePicker 아이템 간 간격 (8) + static let languagePickerSpacing: CGFloat = 8 + + /// LanguagePicker 라디오 버튼 크기 (16) + static let languagePickerIndicatorSize: CGFloat = 16 + + /// LanguagePicker 선택된 내부 점 크기 (10) + static let languagePickerInnerIndicatorSize: CGFloat = 10 + + /// LanguagePicker 라디오 버튼과 텍스트 사이 간격 (6) + static let languagePickerTitleSpacing: CGFloat = 6 +} + +// MARK: - ScriptCell Constants + +public extension Constant { + /// ScriptCell 기본 간격 (8) — 타임스탬프 여백·본문 내부 패딩 공통 + static let scriptCellSpacing: CGFloat = 8 + + /// ScriptCell 본문 버블 모서리 반경 (8) + static let scriptCellCornerRadius: CGFloat = 8 +} + +// MARK: - MetadataCell Constants + +public extension Constant { + /// MetadataCell 폴더 아이콘과 라벨 사이 간격 (5) + static let metadataCellIconSpacing: CGFloat = 5 + + /// MetadataCell 날짜·재생시간 라벨 사이 간격 (2) + static let metadataCellLineSpacing: CGFloat = 2 + + /// MetadataCell 폴더 행과 날짜 그룹 사이 간격 (11) + static let metadataCellSectionSpacing: CGFloat = 11 + + /// MetadataCell 폴더 아이콘 크기 (20) + static let metadataCellIconSize: CGFloat = 20 +} + +// MARK: - KeyPointCell Constants + +public extension Constant { + /// KeyPointCell 번호 뱃지 크기 (24) — 원형 뱃지의 width·height 기준값. cornerRadius는 이 값의 1/2로 파생 + static let keyPointBadgeSize: CGFloat = 24 + + /// KeyPointCell 뱃지와 본문 텍스트 사이 간격 (8) + static let keyPointContentSpacing: CGFloat = 8 + + /// KeyPointCell 카드 좌우 패딩 (12) + static let keyPointCardHorizontalPadding: CGFloat = 12 + + /// KeyPointCell 카드 상하 패딩 (8) + static let keyPointCardVerticalPadding: CGFloat = 8 +} + +// MARK: - UnderlineTabButton Constants + +public extension Constant { + /// UnderlineTabButton 타이틀과 카운트 사이 간격 (4) + static let underlineTabContentSpacing: CGFloat = 4 + + /// UnderlineTabButton 선택 인디케이터 높이 (2) + static let underlineTabIndicatorHeight: CGFloat = 2 +} + +// MARK: - UnderlineSegmentedControl Constants + +public extension Constant { + /// UnderlineSegmentedControl 표준 높이 (42) + static let underlineSegmentedControlHeight: CGFloat = 42 + + /// UnderlineSegmentedControl 상단 여백 (safeArea 기준, 16) + static let underlineSegmentedControlTopMargin: CGFloat = 16 +} + +// MARK: - SkeletonLineView Constants + +public extension Constant { + /// SkeletonLineView 높이 (14) — cornerRadius는 이 값의 1/2로 파생 + static let skeletonLineHeight: CGFloat = 14 + + /// SkeletonLineView 그라디언트 끝(투명) alpha + static let skeletonLineTrailingAlpha: CGFloat = 0.05 + + /// SkeletonLineView scaleX 애니메이션 시작값 + static let skeletonScaleFrom: CGFloat = 0.1 + + /// SkeletonLineView scaleX 애니메이션 끝값 + static let skeletonScaleTo: CGFloat = 1.0 + + /// SkeletonLineView scaleX 애니메이션 편도 주기 (초) + static let skeletonAnimationDuration: CFTimeInterval = 1.0 + + /// 스켈레톤 핵심 포인트 개수 (3) + static let skeletonKeyPointCount: Int = 3 + + /// 스켈레톤 키워드 개수 (2) + static let skeletonKeywordCount: Int = 2 + + /// 스켈레톤 항목 간 애니메이션 시작 오프셋 간격 (0.2초) + static let skeletonStaggerOffset: Double = 0.2 +} + +// MARK: - BackgroundView Constants + +public extension Constant { + /// 첫 번째 타원 초기 높이 (195) + static let ellipseFirstHeight: CGFloat = 195 + /// 두 번째 타원 초기 높이 (116) + static let ellipseSecondHeight: CGFloat = 116 + /// 첫 번째 타원 초기 Blur (100) + static let ellipseFirstBlur: CGFloat = 100 + /// 두 번째 타원 초기 Blur (40) + static let ellipseSecondBlur: CGFloat = 40 + + /// 첫 번째 타원 Blur 진폭 배율 (250) + static let ellipseFirstBlurAmplitudeMultiplier: CGFloat = 250 + /// 두 번째 타원 Blur 진폭 배율 (100) + static let ellipseSecondBlurAmplitudeMultiplier: CGFloat = 100 + /// 첫 번째 타원 높이 진폭 배율 (643) + static let ellipseFirstHeightAmplitudeMultiplier: CGFloat = 643 + /// 두 번째 타원 높이 진폭 배율 (204) + static let ellipseSecondHeightAmplitudeMultiplier: CGFloat = 204 + + /// 첫 번째 타원 Leading Offset (-16) + static let ellipseFirstLeadingOffset: CGFloat = -16 + /// 첫 번째 타원 Trailing Offset (16) + static let ellipseFirstTrailingOffset: CGFloat = 16 + /// 첫 번째 타원 Bottom Offset (69) + static let ellipseFirstBottomOffset: CGFloat = 69 + /// 두 번째 타원 Bottom Offset (100) + static let ellipseSecondBottomOffset: CGFloat = 100 +} + +// MARK: - VoiceNote Layout Constants + +public extension Constant { + /// BottomFadeView 높이 (192) + static let voiceNoteBottomFadeHeight: CGFloat = 192 + + /// MatchAccessoryBar 좌우 수평 마진 (20) + static let matchAccessoryBarHorizontalMargin: CGFloat = 20 + + /// MatchAccessoryBar 키보드 상단 간격 (8) + static let matchAccessoryBarKeyboardSpacing: CGFloat = 8 +} + +// MARK: - SummarySection Layout Constants + +public extension Constant { + /// SummarySection 좌우 여백 (20) + static let summarySectionHorizontalInset: CGFloat = 20 + + /// SummarySection metadata 셀 상단 여백 (24) + static let summarySectionMetadataTopInset: CGFloat = 24 + + /// SummarySection keyPoints 헤더 상단 여백 (26) + static let summarySectionKeyPointsHeaderTop: CGFloat = 26 + + /// SummarySection keyPoints 셀 상단 여백 (16) + static let summarySectionKeyPointsTopInset: CGFloat = 16 + + /// SummarySection keyPoints 셀 간 간격 (6) + static let summarySectionKeyPointsGroupSpacing: CGFloat = 6 + + /// SummarySection keywords 헤더 상단 여백 (32) + static let summarySectionKeywordsHeaderTop: CGFloat = 32 + + /// SummarySection keywords 셀 상단 여백 (12) + static let summarySectionKeywordsTopInset: CGFloat = 12 +} + +// MARK: - WebView URL + +public extension Constant { + /// 개인정보 처리 방침 + static let privacyPolicy: String = "https://sunset-bar-890.notion.site/369d9da368aa80538cced7f6c56e339a?pvs=74" + /// 이용 약관 + static let termsOfUse: String = "https://sunset-bar-890.notion.site/369d9da368aa8033be62f317299c07f2" + /// 고객 문의 + static let customerInquiry: String = "https://docs.google.com/forms/d/e/1FAIpQLSeevBvqUuIG4yBEos3T6KEZc_R1GgbMLAZYG9iHTc4JMv7DIg/viewform?usp=publish-editor" +} diff --git a/Presentation/Sources/DesignSystem/Font/PretendardFont.swift b/Presentation/Sources/DesignSystem/Font/PretendardFont.swift new file mode 100644 index 00000000..989799b2 --- /dev/null +++ b/Presentation/Sources/DesignSystem/Font/PretendardFont.swift @@ -0,0 +1,15 @@ +import UIKit + +public enum PretendardFont { + public static func bold(size: CGFloat) -> UIFont { + return PresentationFontFamily.Pretendard.bold.font(size: size) + } + + public static func medium(size: CGFloat) -> UIFont { + return PresentationFontFamily.Pretendard.medium.font(size: size) + } + + public static func regular(size: CGFloat) -> UIFont { + return PresentationFontFamily.Pretendard.regular.font(size: size) + } +} diff --git a/Presentation/Sources/DesignSystem/Font/Typography.swift b/Presentation/Sources/DesignSystem/Font/Typography.swift new file mode 100644 index 00000000..b4ea4da9 --- /dev/null +++ b/Presentation/Sources/DesignSystem/Font/Typography.swift @@ -0,0 +1,107 @@ +import SwiftUI +import UIKit + +public enum Typography { + case header1 + case header2 + case title1 + case title2 + case title3 + case subtitle1 + case subtitle2 + case body1 + case body2 + case body3 + case label + case caption + + public var font: UIFont { + switch self { + case .header1: return PretendardFont.medium(size: 28) + case .header2: return PretendardFont.bold(size: 24) + case .title1: return PretendardFont.bold(size: 20) + case .title2: return PretendardFont.bold(size: 18) + case .title3: return PretendardFont.bold(size: 16) + case .subtitle1: return PretendardFont.medium(size: 18) + case .subtitle2: return PretendardFont.medium(size: 16) + case .body1: return PretendardFont.regular(size: 16) + case .body2: return PretendardFont.regular(size: 16) + case .body3: return PretendardFont.regular(size: 15) + case .label: return PretendardFont.regular(size: 15) + case .caption: return PretendardFont.regular(size: 14) + } + } + + /// Figma 스펙의 line-height 비율(폰트 크기 대비). 130% → 1.3, 150% → 1.5. + public var lineHeightRatio: CGFloat { + switch self { + case .header1, .header2, .title1, .title2, .title3, .subtitle2, .body2, .label, .caption: + return 1.3 + case .subtitle1, .body1, .body3: + return 1.5 + } + } + + // TODO: 자간 + public var letterSpacing: CGFloat { + let size = font.pointSize + switch self { + case .header1, .subtitle1: + return 0 + case .header2, .title1, .title2, .title3, .caption: + return size * -0.02 + case .subtitle2, .body1, .body2, .body3, .label: + return size * -0.03 + } + } + + var textAttributes: [NSAttributedString.Key: Any] { + let paragraphStyle = NSMutableParagraphStyle() + let targetLineHeight = font.pointSize * lineHeightRatio + + paragraphStyle.minimumLineHeight = targetLineHeight + paragraphStyle.maximumLineHeight = targetLineHeight + + let baselineOffset = (targetLineHeight - font.lineHeight) / 2 + + return [ + .font: font, + .paragraphStyle: paragraphStyle, + .kern: letterSpacing, + .baselineOffset: baselineOffset + ] + } +} + +public extension UILabel { + /// Typography를 적용합니다. + /// - Parameters: + /// - text: UILabel의 텍스트 입니다. + /// - typography: 글씨체, 행간 , 자간 복합적인 열겨형 데이터 + /// - textAlignment: 텍스트 정렬 설정 (기본값: .left) + func setTypography(text: String? = nil, style typography: Typography, textAlignment: NSTextAlignment = .left) { + let textToUse = text ?? self.text ?? "" + var attributes = typography.textAttributes + + if let paragraphStyle = (attributes[.paragraphStyle] as? NSParagraphStyle)? + .mutableCopy() as? NSMutableParagraphStyle + { + paragraphStyle.alignment = textAlignment + attributes[.paragraphStyle] = paragraphStyle + } + + attributedText = NSAttributedString(string: textToUse, attributes: attributes) + } +} + +public extension View { + /// SwiftUI View에 Typography를 적용합니다. + func typography(_ style: Typography) -> some View { + let targetLineHeight = style.font.pointSize * style.lineHeightRatio + let spacing = targetLineHeight - style.font.lineHeight + + return font(Font(style.font)) + .tracking(style.letterSpacing) + .lineSpacing(max(0, spacing)) + } +} diff --git a/Presentation/Sources/DesignSystem/Text+HighlightedMultipleText.swift b/Presentation/Sources/DesignSystem/Text+HighlightedMultipleText.swift new file mode 100644 index 00000000..a3a8db9a --- /dev/null +++ b/Presentation/Sources/DesignSystem/Text+HighlightedMultipleText.swift @@ -0,0 +1,38 @@ +import Domain +import SwiftUI + +extension Text { + /// 문자열에서 키워드를 통해 강조색을 표현하는 이니셜라이저 + /// - Parameters: + /// - fullText: 전체 문장을 넣습니다. + /// - keyword: 강조하고 싶은 String 키워드 + init(fullText: String, keyword: String) { + guard !keyword.isEmpty, fullText.localizedCaseInsensitiveContains(keyword) else { + self.init(fullText) + return + } + + var attributedString = AttributedString(fullText) + + // 검색할 전체 범위 + var searchRange = attributedString.startIndex ..< attributedString.endIndex + + // 범위 내에 키워드가 존재하는 한 계속 반복해서 찾습니다. + while let range = attributedString[searchRange].range(of: keyword, options: .caseInsensitive) { + // 스타일 적용 + attributedString[range].foregroundColor = .point900 + + // 찾은 부분 다음부터 다시 검색하도록 범위를 업데이트 + searchRange = range.upperBound ..< attributedString.endIndex + } + + self.init(attributedString) + } + + init(transcript: Transcript, keyword: String) { + let targetText = transcript.sections.first { $0.text.localizedCaseInsensitiveContains(keyword) }?.text + ?? transcript.sections.first?.text + ?? "" + self.init(fullText: targetText, keyword: keyword) + } +} diff --git a/Presentation/Sources/DesignSystem/UIColor+SemanticTokens.swift b/Presentation/Sources/DesignSystem/UIColor+SemanticTokens.swift new file mode 100644 index 00000000..7862924d --- /dev/null +++ b/Presentation/Sources/DesignSystem/UIColor+SemanticTokens.swift @@ -0,0 +1,18 @@ +import UIKit + +public extension UIColor { + /// 재생 중인 스크립트 셀 강조 배경 + static var scriptCellHighlight: UIColor { + .point600.withAlphaComponent(0.3) + } + + /// 음성 메모 메타데이터(폴더·날짜·재생시간) 표시 색상 + static var metadataLabel: UIColor { + .gray750 + } + + /// 편집 모드 진입 시 본문 영역을 가리는 딤 오버레이 색상 + static var dimBackground: UIColor { + .black.withAlphaComponent(0.6) + } +} diff --git a/Presentation/Sources/DesignSystem/UIImage+Gradient.swift b/Presentation/Sources/DesignSystem/UIImage+Gradient.swift new file mode 100644 index 00000000..bc1813bd --- /dev/null +++ b/Presentation/Sources/DesignSystem/UIImage+Gradient.swift @@ -0,0 +1,28 @@ +import Foundation +import UIKit + +public extension UIImage { + convenience init?(bounds: CGRect, colors: [UIColor], orientation: GradientOrientation = .horizontal) { + let gradientLayer = CAGradientLayer() + gradientLayer.frame = bounds + gradientLayer.colors = colors.map(\.cgColor) + + if orientation == .horizontal { + gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5) + } + + UIGraphicsBeginImageContext(gradientLayer.bounds.size) + gradientLayer.render(in: UIGraphicsGetCurrentContext()!) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + guard let cgImage = image?.cgImage else { return nil } + self.init(cgImage: cgImage) + } +} + +public enum GradientOrientation { + case vertical + case horizontal +} diff --git a/Presentation/Sources/DesignSystem/UITapGestureRecognizer+Extensions.swift b/Presentation/Sources/DesignSystem/UITapGestureRecognizer+Extensions.swift new file mode 100644 index 00000000..bdb3156e --- /dev/null +++ b/Presentation/Sources/DesignSystem/UITapGestureRecognizer+Extensions.swift @@ -0,0 +1,28 @@ +import UIKit + +// MARK: - BlockTapGestureRecognizer + +public final class BlockTapGestureRecognizer: UITapGestureRecognizer { + private var action: () -> Void + + public init(action: @escaping () -> Void) { + self.action = action + super.init(target: nil, action: nil) + addTarget(self, action: #selector(handleTap)) + } + + @objc + private func handleTap() { + action() + } +} + +// MARK: - UIView+TapGesture + +public extension UIView { + func addTapGesture(action: @escaping () -> Void) { + let tap = BlockTapGestureRecognizer(action: action) + addGestureRecognizer(tap) + isUserInteractionEnabled = true + } +} diff --git a/Presentation/Sources/DesignSystem/UIView+GlassEffect.swift b/Presentation/Sources/DesignSystem/UIView+GlassEffect.swift new file mode 100644 index 00000000..01107be0 --- /dev/null +++ b/Presentation/Sources/DesignSystem/UIView+GlassEffect.swift @@ -0,0 +1,27 @@ +import UIKit + +extension UIView { + func applyGlassEffect( + cornerRadius: CGFloat = 20, + isInteractive: Bool = false, + tintColor: UIColor + ) { + // 중복 추가 방지 + if subviews.contains(where: { $0 is UIVisualEffectView }) { return } + let glassEffect = UIGlassEffect(style: .clear) + UIView.animate { + glassEffect.isInteractive = isInteractive + glassEffect.tintColor = tintColor + } + let visualEffectView = UIVisualEffectView(effect: glassEffect) + visualEffectView.cornerConfiguration = .corners(radius: .fixed(cornerRadius)) + visualEffectView.translatesAutoresizingMaskIntoConstraints = false + insertSubview(visualEffectView, at: 0) + NSLayoutConstraint.activate([ + visualEffectView.topAnchor.constraint(equalTo: topAnchor), + visualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + visualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + visualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} diff --git a/Presentation/Sources/DesignSystem/UIView+ToastMessage.swift b/Presentation/Sources/DesignSystem/UIView+ToastMessage.swift new file mode 100644 index 00000000..a83a836d --- /dev/null +++ b/Presentation/Sources/DesignSystem/UIView+ToastMessage.swift @@ -0,0 +1,121 @@ +import UIKit + +extension UIView { + enum ToastType: Hashable { + case normal + case action + } + + func makeToast( + type: ToastType = .action, + _ message: String, + duration: TimeInterval = 3.0, + action: (() -> Void)? = nil + ) { + let toastContainer = UIView() + toastContainer.translatesAutoresizingMaskIntoConstraints = false + toastContainer.backgroundColor = UIColor.gray100 + toastContainer.layer.borderColor = UIColor.gray350.cgColor + toastContainer.layer.borderWidth = 1.0 + toastContainer.layer.cornerRadius = 20 + toastContainer.alpha = 0.0 + + let msgLabel = UILabel() + msgLabel.translatesAutoresizingMaskIntoConstraints = false + msgLabel.textColor = UIColor.gray800 + msgLabel.numberOfLines = 1 + msgLabel.setTypography(text: message, style: .body2, textAlignment: type == .normal ? .center : .left) + + let cancelButton = UIButton() + cancelButton.translatesAutoresizingMaskIntoConstraints = false + cancelButton.setTitle("취소", for: .normal) + cancelButton.titleLabel?.setTypography(style: .body2) + cancelButton.setTitleColor(.danger, for: .normal) + cancelButton.backgroundColor = .clear + cancelButton.isHidden = type == .normal + // 액션 버튼을 눌렀을 때 이벤트 처리 + if type == .action { + cancelButton.addAction(UIAction { _ in + action?() + + // 버튼을 누르면 대기(delay)를 무시하고 곧바로 내려가면서 사라지도록 처리합니다. + UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseIn, .beginFromCurrentState]) { + toastContainer.alpha = 0.0 + toastContainer.transform = CGAffineTransform(translationX: 0, y: 50) + } completion: { _ in + toastContainer.removeFromSuperview() + } + }, for: .touchUpInside) + } + + toastContainer.addSubview(msgLabel) + toastContainer.addSubview(cancelButton) + + let targetView: UIView = if let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows) + .first(where: { $0.isKeyWindow }) + { + window + } else { + self + } + + targetView.addSubview(toastContainer) + + // 타입에 따라 AutoLayout 제약조건 분기 + var constraints: [NSLayoutConstraint] = [ + toastContainer.bottomAnchor.constraint(equalTo: targetView.safeAreaLayoutGuide.bottomAnchor, constant: -20), + toastContainer.leadingAnchor.constraint(equalTo: targetView.leadingAnchor, constant: 20), + toastContainer.trailingAnchor.constraint(equalTo: targetView.trailingAnchor, constant: -20), + toastContainer.heightAnchor.constraint(equalToConstant: 52), + + msgLabel.centerYAnchor.constraint(equalTo: toastContainer.centerYAnchor), + msgLabel.leadingAnchor.constraint(equalTo: toastContainer.leadingAnchor, constant: 16) + ] + + if type == .normal { + constraints.append(msgLabel.trailingAnchor.constraint( + equalTo: toastContainer.trailingAnchor, + constant: -16 + )) + } else { + constraints.append(contentsOf: [ + msgLabel.trailingAnchor.constraint(lessThanOrEqualTo: cancelButton.leadingAnchor, constant: -10), + cancelButton.centerYAnchor.constraint(equalTo: toastContainer.centerYAnchor), + cancelButton.trailingAnchor.constraint(equalTo: toastContainer.trailingAnchor, constant: -16) + ]) + } + + NSLayoutConstraint.activate(constraints) + + toastContainer.transform = CGAffineTransform(translationX: 0, y: 50) + + // 1. 나타날 때 (.allowUserInteraction 옵션 필수! 안 넣으면 delay 중첩 시간 동안 버튼 터치가 완전 무시됩니다.) + UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseOut, .allowUserInteraction]) { + toastContainer.alpha = 1.0 + toastContainer.transform = .identity + } + + // 2. 유지 및 사라질 때 (Task를 사용하여 명시적으로 딜레이시킴으로써 Hit Target 유실 방지) + Task { + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + + await MainActor.run { + guard toastContainer.superview != nil else { return } + + UIView.animate( + withDuration: 0.3, + delay: 0.0, + options: [.curveEaseIn, .allowUserInteraction], + animations: { + toastContainer.alpha = 0.0 + toastContainer.transform = CGAffineTransform(translationX: 0, y: 50) + } + ) { _ in + toastContainer.removeFromSuperview() + } + } + } + } +} diff --git a/Presentation/Sources/DesignSystem/UIViewController+Extension.swift b/Presentation/Sources/DesignSystem/UIViewController+Extension.swift new file mode 100644 index 00000000..6a542266 --- /dev/null +++ b/Presentation/Sources/DesignSystem/UIViewController+Extension.swift @@ -0,0 +1,233 @@ +import UIKit + +// MARK: UIViewController + +public class ViewController: UIViewController { + lazy var chagokBackgroundView: ChaGokBackgroundView = .init() + + override public func loadView() { + view = chagokBackgroundView + } +} + +public extension UIViewController { + func updateNavigationBarAppearance(isTransparent: Bool) { + let appearance = UINavigationBarAppearance() + if isTransparent { + appearance.configureWithTransparentBackground() + } else { + appearance.configureWithDefaultBackground() + appearance.backgroundColor = UIColor.gray50 + } + + navigationItem.standardAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + } +} + +// MARK: UICollectionViewController + +public class CollectionViewController: UICollectionViewController { + lazy var chagokBackgroundView: ChaGokBackgroundView = .init() + + override public func viewDidLoad() { + super.viewDidLoad() + collectionView.backgroundView = chagokBackgroundView + collectionView.backgroundColor = .clear + } +} + +// MARK: UICollectionView + +public class CollectionView: UICollectionView { + lazy var chagokBackgroundView: ChaGokBackgroundView = .init() + + override public init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { + super.init(frame: frame, collectionViewLayout: layout) + backgroundView = chagokBackgroundView + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + backgroundView = chagokBackgroundView + } +} + +// MARK: UITableViewController + +public class TableViewController: UITableViewController { + lazy var chagokBackgroundView: ChaGokBackgroundView = .init() + + override public func viewDidLoad() { + super.viewDidLoad() + tableView.backgroundView = chagokBackgroundView + tableView.backgroundColor = .clear + } +} + +// MARK: UITableView + +public class TableView: UITableView { + lazy var chagokBackgroundView: ChaGokBackgroundView = .init() + + override public init(frame: CGRect, style: UITableView.Style) { + super.init(frame: frame, style: style) + backgroundView = chagokBackgroundView + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + backgroundView = chagokBackgroundView + } +} + +// MARK: - Background 커스텀 뷰 + +final class ChaGokBackgroundView: UIView { + let ellipseFirst: UIView = { + let e = UIView() + e.translatesAutoresizingMaskIntoConstraints = false + return e + }() + + let ellipseSecond: UIView = { + let e = UIView() + e.translatesAutoresizingMaskIntoConstraints = false + return e + }() + + // 제약조건(Constraint)을 애니메이션 시점에 변경하기 위해 참조를 유지합니다. + private var ellipseFirstHeightConstraint: NSLayoutConstraint? + private var ellipseSecondHeightConstraint: NSLayoutConstraint? + + var animationValue: AnimationValue = .init() + var amplitude: Amplitude = .init() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + setupEllipseFirst() + setupEllipseSecond() + } + + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + ellipseFirstStyle() + ellipseSecondStyle() + } + + override func updateProperties() { + super.updateProperties() + let amp = CGFloat(amplitude.value) + + // 속성(Properties) 업데이트: Blur 반경 + let firstBlur = animationValue.ellipseFirstBlur + (amp * Constant.ellipseFirstBlurAmplitudeMultiplier) + let secondBlur = animationValue.ellipseSecondBlur + (amp * Constant.ellipseSecondBlurAmplitudeMultiplier) + ellipseFirst.layer.shadowRadius = firstBlur + ellipseSecond.layer.shadowRadius = secondBlur + // background Color + ellipseFirst.layer.shadowColor = amp == 0.0 ? UIColor.point300.cgColor : UIColor.point500.cgColor + ellipseSecond.layer.shadowColor = amp == 0.0 ? UIColor.point500.cgColor : UIColor.point600.cgColor + setNeedsUpdateConstraints() + } + + override func updateConstraints() { + super.updateConstraints() + let amp = CGFloat(amplitude.value) + // 제약조건(Constraints) 업데이트: 높이 + ellipseFirstHeightConstraint?.constant = animationValue + .ellipseFirstHeight + (amp * Constant.ellipseFirstHeightAmplitudeMultiplier) + ellipseSecondHeightConstraint?.constant = animationValue + .ellipseSecondHeight + (amp * Constant.ellipseSecondHeightAmplitudeMultiplier) + } + + private func setup() { + backgroundColor = UIColor.gray50 + addSubview(ellipseFirst) + addSubview(ellipseSecond) + } + + private func setupEllipseFirst() { + let heightConstraint = ellipseFirst.heightAnchor.constraint(equalToConstant: animationValue.ellipseFirstHeight) + ellipseFirstHeightConstraint = heightConstraint + + NSLayoutConstraint.activate([ + ellipseFirst.centerXAnchor.constraint(equalTo: centerXAnchor), + ellipseFirst.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constant.ellipseFirstLeadingOffset), + ellipseFirst.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: Constant.ellipseFirstTrailingOffset + ), + heightConstraint, + ellipseFirst.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constant.ellipseFirstBottomOffset) + ]) + } + + private func setupEllipseSecond() { + let heightConstraint = ellipseSecond.heightAnchor + .constraint(equalToConstant: animationValue.ellipseSecondHeight) + ellipseSecondHeightConstraint = heightConstraint + + NSLayoutConstraint.activate([ + ellipseSecond.centerXAnchor.constraint(equalTo: centerXAnchor), + ellipseSecond.leadingAnchor.constraint(equalTo: leadingAnchor), + ellipseSecond.trailingAnchor.constraint(equalTo: trailingAnchor), + heightConstraint, + ellipseSecond.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constant.ellipseSecondBottomOffset) + ]) + } +} + +// MARK: Ellipse Data 구조 + +extension ChaGokBackgroundView { + @Observable + final class Amplitude { + var value: Float + + init(value: Float = 0.0) { + self.value = value + } + } + + struct AnimationValue { + // 제약조건의 높이 최소/최대 (원하시는 수치로 언제든 수정 가능합니다) + var ellipseFirstHeight: CGFloat = Constant.ellipseFirstHeight + var ellipseSecondHeight: CGFloat = Constant.ellipseSecondHeight + // Blur(그림자 흐림 반경) + var ellipseFirstBlur: CGFloat = Constant.ellipseFirstBlur + var ellipseSecondBlur: CGFloat = Constant.ellipseSecondBlur + } +} + +// MARK: Ellipse Style + +extension ChaGokBackgroundView { + private func ellipseFirstStyle() { + let path = UIBezierPath(ovalIn: ellipseFirst.bounds) + ellipseFirst.backgroundColor = .clear + ellipseFirst.layer.shadowOpacity = 1.0 + ellipseFirst.layer.shadowOffset = .zero + ellipseFirst.layer.shadowPath = path.cgPath + } + + private func ellipseSecondStyle() { + let rectForHalfEllipse = CGRect( + x: 0, + y: 0, + width: ellipseSecond.bounds.width, + height: ellipseSecond.bounds.height * 2 + ) + // 반타원 패스 + let path = UIBezierPath(ovalIn: rectForHalfEllipse) + ellipseSecond.backgroundColor = .clear + ellipseSecond.layer.shadowOpacity = 1.0 + ellipseSecond.layer.shadowOffset = .zero + ellipseSecond.layer.shadowPath = path.cgPath + } +} diff --git a/Presentation/Sources/DesignSystem/UIVisualEffectView+GradientBorder.swift b/Presentation/Sources/DesignSystem/UIVisualEffectView+GradientBorder.swift new file mode 100644 index 00000000..6bfa7dc9 --- /dev/null +++ b/Presentation/Sources/DesignSystem/UIVisualEffectView+GradientBorder.swift @@ -0,0 +1,120 @@ +import UIKit + +private final class GradientBorderOverlayView: UIView { + private let gradientLayer = CAGradientLayer() + private let borderMaskLayer = CAShapeLayer() + + private var borderWidth: CGFloat = 1 + private var borderCornerRadius: CGFloat = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + + isUserInteractionEnabled = false + backgroundColor = .clear + + borderMaskLayer.fillColor = UIColor.clear.cgColor + borderMaskLayer.strokeColor = UIColor.black.cgColor + gradientLayer.mask = borderMaskLayer + + layer.addSublayer(gradientLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + func configure( + colors: [UIColor], + width: CGFloat, + cornerRadius: CGFloat, + startPoint: CGPoint, + endPoint: CGPoint + ) { + borderWidth = width + borderCornerRadius = cornerRadius + gradientLayer.colors = colors.map(\.cgColor) + gradientLayer.startPoint = startPoint + gradientLayer.endPoint = endPoint + borderMaskLayer.lineWidth = width + + setNeedsLayout() + } + + func updateCornerRadius(_ cornerRadius: CGFloat) { + borderCornerRadius = cornerRadius + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + + gradientLayer.frame = bounds + + let inset = borderWidth / 2 + let borderRect = bounds.insetBy(dx: inset, dy: inset) + let path = UIBezierPath( + roundedRect: borderRect, + cornerRadius: max(borderCornerRadius - inset, 0) + ) + + borderMaskLayer.path = path.cgPath + } +} + +private extension UIVisualEffectView { + var gradientBorderOverlayView: GradientBorderOverlayView? { + contentView.subviews.first { $0 is GradientBorderOverlayView } as? GradientBorderOverlayView + } +} + +extension UIVisualEffectView { + func setGradientBorder( + colors: [UIColor], + width: CGFloat = 1, + cornerRadius: CGFloat, + startPoint: CGPoint = CGPoint(x: 0, y: 0.5), + endPoint: CGPoint = CGPoint(x: 1, y: 0.5) + ) { + guard !colors.isEmpty else { + removeGradientBorder() + return + } + + let overlayView: GradientBorderOverlayView + + if let existingOverlay = gradientBorderOverlayView { + overlayView = existingOverlay + } else { + overlayView = GradientBorderOverlayView() + overlayView.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(overlayView) + NSLayoutConstraint.activate([ + overlayView.topAnchor.constraint(equalTo: contentView.topAnchor), + overlayView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + overlayView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + overlayView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + overlayView.configure( + colors: colors, + width: width, + cornerRadius: cornerRadius, + startPoint: startPoint, + endPoint: endPoint + ) + + contentView.bringSubviewToFront(overlayView) + } + + func updateGradientBorderCornerRadius(_ cornerRadius: CGFloat) { + gradientBorderOverlayView?.updateCornerRadius(cornerRadius) + } + + func removeGradientBorder() { + gradientBorderOverlayView?.removeFromSuperview() + } +} diff --git a/Presentation/Sources/Helper/String+SearchHighlight.swift b/Presentation/Sources/Helper/String+SearchHighlight.swift new file mode 100644 index 00000000..6ed3e250 --- /dev/null +++ b/Presentation/Sources/Helper/String+SearchHighlight.swift @@ -0,0 +1,71 @@ +import UIKit + +extension String { + /// 문자열 내에서 `query`에 매칭되는 모든 `NSRange`를 반환합니다. + /// - Parameters: + /// - query: 찾을 문자열. 비어 있으면 빈 배열을 반환합니다. + /// - options: 비교 옵션 (기본값: 대소문자 무시). + func ranges(of query: String, options: String.CompareOptions = [.caseInsensitive]) -> [NSRange] { + guard !query.isEmpty else { return [] } + let nsString = self as NSString + var ranges: [NSRange] = [] + var searchStart = 0 + let totalLength = nsString.length + + while searchStart < totalLength { + let remaining = NSRange(location: searchStart, length: totalLength - searchStart) + let found = nsString.range(of: query, options: options, range: remaining) + guard found.location != NSNotFound else { break } + ranges.append(found) + searchStart = found.location + max(found.length, 1) + } + + return ranges + } + + /// `query` 매치 영역에 형광펜 스타일의 배경 하이라이트를 적용한 `NSAttributedString`을 반환합니다. + /// 매치 영역의 글자색은 `gray950`으로 고정되어 배경 위에서 가독성을 보장합니다. + /// `focusedRange`가 지정되면 해당 범위는 `focusedHighlightBackgroundColor`로 덮어씌웁니다. + func highlighted( + query: String, + baseAttributes: [NSAttributedString.Key: Any], + highlightBackgroundColor: UIColor, + focusedRange: NSRange? = nil, + focusedHighlightBackgroundColor: UIColor? = nil + ) -> NSAttributedString { + highlighted( + ranges: ranges(of: query), + baseAttributes: baseAttributes, + highlightBackgroundColor: highlightBackgroundColor, + focusedRange: focusedRange, + focusedHighlightBackgroundColor: focusedHighlightBackgroundColor + ) + } + + /// 미리 계산된 `ranges`에 형광펜 스타일의 배경 하이라이트를 적용한 `NSAttributedString`을 반환합니다. + func highlighted( + ranges: [NSRange], + baseAttributes: [NSAttributedString.Key: Any], + highlightBackgroundColor: UIColor, + focusedRange: NSRange? = nil, + focusedHighlightBackgroundColor: UIColor? = nil + ) -> NSAttributedString { + let attributed = NSMutableAttributedString(string: self, attributes: baseAttributes) + guard !ranges.isEmpty else { return attributed } + + for range in ranges { + attributed.addAttribute(.backgroundColor, value: highlightBackgroundColor, range: range) + attributed.addAttribute(.foregroundColor, value: UIColor.gray950, range: range) + } + + if let focusedRange, let focusedHighlightBackgroundColor, + focusedRange.location != NSNotFound, + focusedRange.location + focusedRange.length <= (self as NSString).length + { + attributed.addAttribute(.backgroundColor, value: focusedHighlightBackgroundColor, range: focusedRange) + attributed.addAttribute(.foregroundColor, value: UIColor.gray50, range: focusedRange) + } + + return attributed + } +} diff --git a/Presentation/Sources/View/Alert/ChaGokAlertViewController.swift b/Presentation/Sources/View/Alert/ChaGokAlertViewController.swift new file mode 100644 index 00000000..c8d46cd7 --- /dev/null +++ b/Presentation/Sources/View/Alert/ChaGokAlertViewController.swift @@ -0,0 +1,154 @@ +import Domain +import UIKit + +public final class ChaGokAlertViewController: UIViewController { + private var cancelButton: GlassButton = .init() + private let primaryButton: GlassButton = .init() + private var alertView: AlertView? + private var textFieldView: TextFieldView? + private weak var currentContentView: UIView? + public weak var delegate: ChaGokAlertButtonTappedDelegate? + + public var inputText: String? { + textFieldView?.field.text + } + + public func setErrorMessage(_ message: String?) { + textFieldView?.setErrorMessage(message) + } + + // MARK: - Initialize + + private let vm: ChaGokAlertViewModel + + public init(vm: ChaGokAlertViewModel) { + self.vm = vm + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + isModalInPresentation = true + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + setup() + setupActions() + render(vm.state) + } + + // MARK: - setup + + private func setup() { + view.backgroundColor = UIColor.black.withAlphaComponent(0.6) + } + + private func setupActions() { + cancelButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + vm.didTapCancel(delegate: delegate, alertVC: self) + }, + for: .touchUpInside + ) + + primaryButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + vm.didTapPrimary(delegate: delegate, alertVC: self) + }, + for: .touchUpInside + ) + } + + /// Componenet 중 AlertView를 초기화 합니다. + private func setAlertView(state: ChaGokAlertViewModel.AlertState, subTitle: String) { + let alertView = AlertView( + title: state.header.title, + subTitle: subTitle, + closeButton: cancelButton, + primaryButton: primaryButton + ) + self.alertView = alertView + attachContentView(alertView) + } + + /// Componenet 중 TextFieldView 를 초기화 합니다. + private func setTextFieldAlertView( + state: ChaGokAlertViewModel.AlertState, + field: TextFieldView.Field, + subTitle: String + ) { + field.title = state.header.title + field.subTitle = subTitle + let textFieldView = TextFieldView( + field: field, + cancelButton: cancelButton, + primaryButton: primaryButton + ) + self.textFieldView = textFieldView + attachContentView(textFieldView, needsWidthConstraint: true, respectKeyboard: true) + } + + private func attachContentView( + _ contentView: UIView, + needsWidthConstraint: Bool = false, + respectKeyboard: Bool = false + ) { + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(contentView) + currentContentView = contentView + + var constraints: [NSLayoutConstraint] = [] + + if respectKeyboard { + let containerGuide = UILayoutGuide() + view.addLayoutGuide(containerGuide) + + constraints.append(contentsOf: [ + containerGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + containerGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + containerGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + containerGuide.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), + + contentView.centerXAnchor.constraint(equalTo: containerGuide.centerXAnchor), + contentView.centerYAnchor.constraint(equalTo: containerGuide.centerYAnchor), + contentView.topAnchor.constraint(greaterThanOrEqualTo: containerGuide.topAnchor, constant: 20), + contentView.bottomAnchor.constraint(lessThanOrEqualTo: containerGuide.bottomAnchor, constant: -20) + ]) + } else { + constraints.append(contentsOf: [ + contentView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + contentView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + if needsWidthConstraint { + constraints.append(contentView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8)) + } + NSLayoutConstraint.activate(constraints) + } +} + +// MARK: Component 초기화 + +extension ChaGokAlertViewController { + private func render(_ state: ChaGokAlertViewModel.AlertState) { + cancelButton.apply(state.cancelButtonStyle) + primaryButton.apply(state.primaryButtonStyle) + + currentContentView?.removeFromSuperview() + + switch state.bodyStyle { + case .basic(let subTitle): + setAlertView(state: state, subTitle: subTitle) + case .textField(let field, let subTitle): + setTextFieldAlertView(state: state, field: field, subTitle: subTitle) + } + } +} diff --git a/Presentation/Sources/View/EmptyListCell.swift b/Presentation/Sources/View/EmptyListCell.swift new file mode 100644 index 00000000..4226d44c --- /dev/null +++ b/Presentation/Sources/View/EmptyListCell.swift @@ -0,0 +1,63 @@ +import UIKit + +struct EmptyContentConfiguration: UIContentConfiguration { + let message: String + func makeContentView() -> any UIView & UIContentView { + EmptyContentView(configuration: self, message: message) + } + + func updated(for state: any UIConfigurationState) -> EmptyContentConfiguration { + self + } +} + +final class EmptyContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + let message: String + + // MARK: - Component + + private lazy var messageLabel: UILabel = { + let l = UILabel() + l.translatesAutoresizingMaskIntoConstraints = false + l.setTypography(text: message, style: .subtitle2) + l.numberOfLines = 0 + l.textColor = UIColor.gray600 + l.textAlignment = .center + return l + }() + + // MARK: Initialize + + init(configuration: UIContentConfiguration, message: String) { + self.configuration = configuration + self.message = message + super.init(frame: .zero) + setup() + apply(configuration: configuration) + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - SetUp + + private func setup() { + addSubview(messageLabel) + NSLayoutConstraint.activate([ + messageLabel.topAnchor.constraint(equalTo: topAnchor, constant: 96), + messageLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + messageLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -96) + ]) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard configuration is EmptyContentConfiguration else { return } + } +} diff --git a/Presentation/Sources/View/Folder/Cell/FolderViewCell.swift b/Presentation/Sources/View/Folder/Cell/FolderViewCell.swift new file mode 100644 index 00000000..ee4a9fac --- /dev/null +++ b/Presentation/Sources/View/Folder/Cell/FolderViewCell.swift @@ -0,0 +1,98 @@ +import Domain +import UIKit + +final class FolderViewCell: UITableViewCell { + static let reuseIdentifier: String = "FolderViewCell" + + // MARK: - Component + + private let prefixImage: UIImageView = { + let img = UIImageView() + img.translatesAutoresizingMaskIntoConstraints = false + img.contentMode = .scaleAspectFit + return img + }() + + private let titleLabel: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.textColor = UIColor.gray800 + return t + }() + + private let countLabel: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.textColor = UIColor.gray800 + return t + }() + + // MARK: - Initialize + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + } + + // MARK: - LifeCycle + + override func layoutSubviews() { + super.layoutSubviews() + contentView.frame.inset(by: UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)) + } + + // MARK: - Setup + + private func setup() { + backgroundColor = UIColor.gray50 + contentView.addSubview(prefixImage) + contentView.addSubview(titleLabel) + contentView.addSubview(countLabel) + setupConstraint() + } + + private func setupConstraint() { + NSLayoutConstraint.activate([ + prefixImage.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + prefixImage.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + prefixImage.widthAnchor.constraint(equalToConstant: 24), + prefixImage.heightAnchor.constraint(equalToConstant: 24), + + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.leadingAnchor.constraint(equalTo: prefixImage.trailingAnchor, constant: 12), + + countLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + countLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16) + ]) + } + + // MARK: - Helper + + func configure(with item: ContentItem) { + switch item { + case .folder(let folder): + prefixImage.image = UIImage(systemName: "folder.fill") + prefixImage.tintColor = UIColor.gray600 + titleLabel.setTypography(text: folder.name, style: .body2) + countLabel.setTypography(text: "\(folder.voiceNoteIDs.count)", style: .body2) + case .voiceNote(let voiceNote): + prefixImage.image = UIImage(systemName: "waveform") + prefixImage.tintColor = UIColor.gray600 + titleLabel.setTypography(text: voiceNote.title, style: .body2) + + let minutes = Int(voiceNote.voiceRecord.duration) / 60 + let seconds = Int(voiceNote.voiceRecord.duration) % 60 + let durationString = String(format: "%02d:%02d", minutes, seconds) + countLabel.setTypography(text: durationString, style: .body2) + } + } +} diff --git a/Presentation/Sources/View/Folder/FolderDetailViewController.swift b/Presentation/Sources/View/Folder/FolderDetailViewController.swift new file mode 100644 index 00000000..8619fd64 --- /dev/null +++ b/Presentation/Sources/View/Folder/FolderDetailViewController.swift @@ -0,0 +1,395 @@ +import Domain +import SwiftUI +import UIKit + +public final class FolderDetailViewController: CollectionViewController { + enum Section { + case main + } + + typealias DataSource = UICollectionViewDiffableDataSource + typealias SnapShot = NSDiffableDataSourceSnapshot + private var listConfiguration: UICollectionLayoutListConfiguration = .init(appearance: .plain) + private var dataSource: DataSource! + + // MARK: - Component + + private lazy var backButton: NavigationItemButton = .init( + normalItem: .init(title: vm.title, imageName: "chevron.left"), + selectedItem: .init(title: "", imageName: "xmark"), + attributedString: Typography.title1.textAttributes + ) + + private lazy var moreAndActionButton: NavigationItemButton = .init( + normalItem: .init(imageName: "ellipsis"), + selectedItem: .init(title: "삭제"), + attributedString: Typography.title1.textAttributes, + selectedForegroundColor: .danger + ) + + private lazy var searchAndMoveButton: NavigationItemButton = .init( + normalItem: .init(imageName: "magnifyingglass"), + selectedItem: .init(title: "이동"), + attributedString: Typography.title1.textAttributes + ) + + private lazy var createdAtAction = UIAction( + title: "생성일 순" + ) { [weak self] _ in + self?.vm.setOrder(.createdAt) + } + + private lazy var updatedAtAction = UIAction( + title: "수정일 순" + ) { [weak self] _ in + self?.vm.setOrder(.updatedAt) + } + + private lazy var selectAction = UIAction( + title: "선택하기", + image: nil + ) { [weak self] _ in + self?.vm.setSelectionMode(.multiple) + } + + private lazy var selectAllAction = UIAction( + title: "전체 선택하기", + image: nil + ) { [weak self] _ in + self?.vm.setSelectionMode(.all) + } + + private let vm: FolderDetailViewModel + + public init(vm: FolderDetailViewModel) { + self.vm = vm + listConfiguration.backgroundColor = .clear + listConfiguration.showsSeparators = false + let layout = UICollectionViewCompositionalLayout.list(using: listConfiguration) + super.init(collectionViewLayout: layout) + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + collectionView.allowsSelection = false + updateNavigationBarAppearance(isTransparent: false) + setupNavigation() + setupSwipeAction() + setupDataSource() + updateDataSource() + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + vm.onAppear() + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + vm.onDisappear() + } + + override public func updateProperties() { + super.updateProperties() + // menu + updateOrder(vm.order) + updateRightBarButtonMenu(vm.select) + // navigation Item + updateNavigationItems(vm.select) + // dataSource + updateDataSource(reconfigure: true) + } + + private func setupNavigation() { + let leftItem = UIBarButtonItem(customView: backButton) + navigationItem.leftBarButtonItem = leftItem + + backButton.addAction(backButtonAction(), for: .touchUpInside) + + if !vm.isTrashMode { + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(customView: moreAndActionButton), + UIBarButtonItem(customView: searchAndMoveButton) + ] + moreAndActionButton.addAction(moreAndActionButtonAction(), for: .touchUpInside) + searchAndMoveButton.addAction(searchAndMoveButtonAction(), for: .touchUpInside) + setupRightBarButtonMenu() + } else { + navigationItem.rightBarButtonItems = [] + } + + navigationItem.leftBarButtonItem?.hidesSharedBackground = true + navigationItem.rightBarButtonItems?.forEach { + $0.hidesSharedBackground = true + } + } + + private func setupSwipeAction() { + listConfiguration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + guard let self else { return nil } + if vm.isTrashMode { return UISwipeActionsConfiguration(actions: []) } + return trailingAction(indexPath: indexPath) + } + + // List 레이아웃을 사용하되, 섹션 설정을 통해 간격을 조정합니다. + let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in + guard let self else { return nil } + let config = listConfiguration + // 개별 셀의 높이가 카드에 딱 맞게 설정되도록 여백 제거 + let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment) + section.interGroupSpacing = 8 + section.contentInsets = .init(top: 12, leading: 20, bottom: 20, trailing: 20) + return section + } + collectionView.setCollectionViewLayout(layout, animated: false) + } + + private func setupRightBarButtonMenu() { + let dateSection: UIMenu = .init( + title: "", + options: .displayInline, + children: [createdAtAction, updatedAtAction] + ) + + let selectSection: UIMenu = .init( + title: "", + options: .displayInline, + children: [selectAction, selectAllAction] + ) + let menu: UIMenu = .init( + title: "", + children: [dateSection, selectSection] + ) + moreAndActionButton.menu = menu + } + + private func setupDataSource() { + let cellRegistration = UICollectionView.CellRegistration { [weak self] ( + cell: UICollectionViewListCell, + indexPath: IndexPath, + itemIdentifier: ContentItem + ) in + guard let self else { return } + var backgroundConfig = UIBackgroundConfiguration.listCell() + backgroundConfig.backgroundColor = .clear + cell.backgroundConfiguration = backgroundConfig + + switch itemIdentifier { + case .folder(let folder): + cell.contentConfiguration = UIHostingConfiguration { + FolderCardView(folder: folder) + } + .margins(.all, 0) + case .voiceNote(let voiceNote): + cell.contentConfiguration = UIHostingConfiguration { + VoiceNoteCardView( + select: vm.select, + isSelected: vm.selectedItems.contains(voiceNote), + voiceNote: voiceNote + ) { [weak self] data, state in + if state { + self?.vm.selectItem(data) + } else { + self?.vm.deselectItem(data) + } + } completeAction: { [weak self] in + self?.vm.pushVoiceNote(voiceNote: voiceNote) + } + } + .margins(.all, 0) + } + } + + dataSource = DataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, itemIdentifier in + return collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: itemIdentifier + ) + } + ) + } +} + +// MARK: - Update Method + +extension FolderDetailViewController { + private func updateOrder(_ order: FolderDetailViewModel.Order) { + switch order { + case .createdAt: + createdAtAction.image = UIImage(systemName: "checkmark") + updatedAtAction.image = nil + case .updatedAt: + createdAtAction.image = nil + updatedAtAction.image = UIImage(systemName: "checkmark") + } + } + + private func updateRightBarButtonMenu(_ select: SelectionMode) { + let dateSection: UIMenu = .init( + title: "", + options: .displayInline, + children: updateDateSectionChildren + ) + + let selectSection: UIMenu = .init( + title: "", + options: .displayInline, + children: updateSelectSectionChildren + ) + let menu: UIMenu = .init( + title: "", + children: [dateSection, selectSection] + ) + moreAndActionButton.menu = menu + } + + private var updateDateSectionChildren: [UIMenuElement] { + switch vm.select { + case .none: + [createdAtAction, updatedAtAction] + case .all, .multiple: + [] + } + } + + private var updateSelectSectionChildren: [UIMenuElement] { + switch vm.select { + case .none: + [selectAction, selectAllAction] + case .all, .multiple: + [] + } + } + + private func updateNavigationItems(_ select: SelectionMode) { + let isEditMode = (select != .none) + for item in [backButton, moreAndActionButton, searchAndMoveButton] { + item.isSelected = isEditMode + item.sizeToFit() + } + moreAndActionButton.showsMenuAsPrimaryAction = !isEditMode + } + + private func updateDataSource(reconfigure: Bool = false) { + var snapshot = SnapShot() + snapshot.appendSections([.main]) + snapshot.appendItems(vm.items) + if reconfigure { + snapshot.reconfigureItems(vm.items) + } + dataSource?.apply(snapshot, animatingDifferences: true) + } +} + +// MARK: - Helper Method + +private extension FolderDetailViewController { + func backButtonAction() -> UIAction { + UIAction { [weak self] _ in + guard let self else { return } + switch vm.select { + case .none: + vm.didTapBack() + case .all, .multiple: + vm.setSelectionMode(.none) + } + } + } + + func moreAndActionButtonAction() -> UIAction { + UIAction { [weak self] _ in + guard let self else { return } + switch vm.select { + case .none: + // TODO: 더 보기 로직 실행 ( 실행 X ) + break + case .multiple, .all: + // TODO: 삭제 로직 실행 + vm.deleteButtonTapped( + alertAction: { + vm.alertCoordinator?.presentAlert( + environment: .moveTrash, + delegate: self + ) + } + ) + } + } + } + + func searchAndMoveButtonAction() -> UIAction { + UIAction { [weak self] _ in + guard let self else { return } + switch vm.select { + case .none: + // TODO: 검색 로직 실행 + vm.pushSearch() + case .all, .multiple: + // TODO: 이동 로직 실행 + vm.presentMoveFolder { [weak self] name in + self?.chagokBackgroundView.makeToast( + type: .normal, + "`\(name)` 폴더로 이동됐어요." + ) + } + vm.setSelectionMode(.none) + } + } + } +} + +// MARK: - Swipe Action Delegate + +public extension FolderDetailViewController { + private func trailingAction(indexPath: IndexPath) -> UISwipeActionsConfiguration { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return .init() } + + let deleteAction = UIContextualAction(style: .destructive, title: nil) { + [weak self] _, _, completion in + if case .voiceNote(let voiceNote) = item { + self?.vm.move(id: voiceNote.id) + self?.updateDataSource() + } + completion(true) + } + deleteAction.image = UIImage(systemName: "trash.fill") + + let configuration = UISwipeActionsConfiguration(actions: [deleteAction]) + configuration.performsFirstActionWithFullSwipe = false + return configuration + } +} + +// MARK: Delegate + +extension FolderDetailViewController: ChaGokAlertButtonTappedDelegate { + public func moveTrashCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) + } + + public func moveTrashPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) { [weak self] in + guard let self else { return } + vm.move() + } + } +} + +#if DEBUG + #Preview("폴더 상세") { + UINavigationController( + rootViewController: FolderDetailViewController( + vm: .preview() + ) + ) + } +#endif diff --git a/Presentation/Sources/View/Folder/FolderViewController.swift b/Presentation/Sources/View/Folder/FolderViewController.swift new file mode 100644 index 00000000..df427b7d --- /dev/null +++ b/Presentation/Sources/View/Folder/FolderViewController.swift @@ -0,0 +1,284 @@ +import Domain +import Observation +import SwiftUI +import UIKit + +public final class FolderViewController: CollectionViewController { + enum Section { + case main + } + + typealias DataSource = UICollectionViewDiffableDataSource + typealias SnapShot = NSDiffableDataSourceSnapshot + private let vm: FolderViewModel + private var dataSource: DataSource! + private var listConfiguration: UICollectionLayoutListConfiguration = .init(appearance: .plain) + + // MARK: - Component + + private lazy var backButton: NavigationItemButton = .init( + normalItem: .init(title: "\(vm.category.title)", imageName: "chevron.left"), + selectedItem: .init(title: "\(vm.category.title)", imageName: "chevron.left"), + attributedString: Typography.title1.textAttributes + ) + + private lazy var searchButton: NavigationItemButton = .init( + normalItem: .init(imageName: "magnifyingglass"), + selectedItem: .init(imageName: "magnifyingglass"), + attributedString: Typography.title1.textAttributes + ) + + private lazy var addButton: NavigationItemButton = .init( + normalItem: .init(imageName: "folder.badge.plus"), + selectedItem: .init(imageName: "folder.badge.plus"), + attributedString: Typography.title1.textAttributes + ) + + // MARK: - Initialize + + public init(vm: FolderViewModel) { + self.vm = vm + listConfiguration.backgroundColor = .clear + listConfiguration.showsSeparators = false + let layout = UICollectionViewCompositionalLayout.list(using: listConfiguration) + super.init(collectionViewLayout: layout) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + setup() + setupNavigationBar() + setupSwipeAction() + setupDataSource() + updateDataSource(animated: false) + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + vm.fetchAll() + } + + override public func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + } + + override public func updateProperties() { + super.updateProperties() + // Naviagation + updateNavigationBarAppearance(isTransparent: false) + // DataSource + updateDataSource() + } + + // MARK: - Setup + + private func setup() { + collectionView.showsVerticalScrollIndicator = false + } + + private func setupNavigationBar() { + vm.showFolderAlert = { [weak self] field in + guard let self else { return } + if field.mode == .create { + vm.alertCoordinator?.presentAlert(environment: .createFolder(field), delegate: self) + } else { + vm.alertCoordinator?.presentAlert(environment: .updateFolder(field), delegate: self) + } + } + + backButton.addAction( + UIAction { [weak self] _ in + self?.vm.didTapBack() + }, for: .touchUpInside + ) + let leftItem = UIBarButtonItem(customView: backButton) + navigationItem.leftBarButtonItem = leftItem + + searchButton.addAction( + UIAction { [weak self] _ in + self?.vm.pushSearch() + }, for: .touchUpInside + ) + + addButton.addAction( + UIAction { [weak self] _ in + self?.vm.openTextField() + }, for: .touchUpInside + ) + let rightSearchItem = UIBarButtonItem(customView: searchButton) + let rightAddItem = UIBarButtonItem(customView: addButton) + navigationItem.rightBarButtonItems = [rightAddItem, rightSearchItem] + navigationItem.leftBarButtonItem?.hidesSharedBackground = true + navigationItem.rightBarButtonItems?.forEach { $0.hidesSharedBackground = true } + } + + /// 오른쪽 Swipe 액션을 제어하는 함수 + private func setupSwipeAction() { + listConfiguration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + self?.trailingAction(indexPath: indexPath) + } + + // List 레이아웃을 사용하되, 섹션 설정을 통해 간격을 조정합니다. + let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in + guard let self else { return nil } + let config = listConfiguration + // 개별 셀의 높이가 카드에 딱 맞게 설정되도록 여백 제거 + let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment) + section.interGroupSpacing = 8 + section.contentInsets = .init(top: 14, leading: 20, bottom: 14, trailing: 20) + return section + } + collectionView.setCollectionViewLayout(layout, animated: false) + } +} + +// MARK: - Diffable DataSource + +extension FolderViewController { + private func setupDataSource() { + let cellRegistraint = UICollectionView + .CellRegistration { cell, indexPath, item in + cell.backgroundConfiguration = .clear() + cell.contentConfiguration = UIHostingConfiguration { + switch item { + case .folder(let data): + FolderCardView( + folder: data, + completeAction: { [weak self] in + self?.vm.pushDetail(data) + } + ) + case .voiceNote(let data): + VoiceNoteCardView( + voiceNote: data + ) + } + } + .margins(.all, 0) + } + + dataSource = DataSource( + collectionView: collectionView, + cellProvider: { col, indexPath, item in + return col.dequeueConfiguredReusableCell(using: cellRegistraint, for: indexPath, item: item) + } + ) + updateDataSource() + } + + private func updateDataSource(animated: Bool = true) { + var snapshot = SnapShot() + snapshot.appendSections([.main]) + snapshot.appendItems(vm.category.items, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: animated) + } +} + +// MARK: - Swipe Action Delegate + +public extension FolderViewController { + private func trailingAction(indexPath: IndexPath) -> UISwipeActionsConfiguration { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return .init() } + + let deleteAction = UIContextualAction(style: .destructive, title: nil) { + [weak self] _, _, completion in + if case .folder(let folder) = item { + self?.vm.move(folder: folder) + // Swipe 종료 애니메이션과 목록 갱신 타이밍이 어긋나면 셀이 튕겨 보일 수 있어 즉시 반영합니다. + self?.updateDataSource(animated: true) + } + completion(true) + } + deleteAction.image = UIImage(systemName: "trash.fill") + + let editAction = UIContextualAction(style: .normal, title: nil) { + [weak self] _, _, completion in + if case .folder(let folder) = item { + self?.vm.openTextField(for: folder) + } + completion(true) + } + editAction.backgroundColor = UIColor.gray500 + editAction.image = UIImage(systemName: "pencil") + + let configuration = UISwipeActionsConfiguration(actions: [deleteAction, editAction]) + configuration.performsFirstActionWithFullSwipe = false + return configuration + } +} + +// MARK: - Cell Touch Delegate + +public extension FolderViewController { + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + // 터치 시 배경색 진해진 상태를 부드럽게 원래대로 돌려줍니다. + collectionView.deselectItem(at: indexPath, animated: true) + + // 클릭한 셀의 데이터를 가져옵니다. + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + + // ContentItem이 folder 모델일 경우 상세 화면으로 이동합니다. + if case .folder(let folder) = item { + vm.pushDetail(folder) + } + } +} + +// MARK: - Delegate + +extension FolderViewController: ChaGokAlertButtonTappedDelegate { + public func createFolderCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) { [weak self] in + self?.vm.closeTextField() + } + } + + public func createFolderPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + guard let name = alertVC.inputText, !name.isEmpty else { return } + vm.create(name: name) + + if let errorMessage = vm.errorMessage { + alertVC.setErrorMessage(errorMessage) + } else { + alertVC.dismiss(animated: true) { [weak self] in + self?.vm.closeTextField() + } + } + } + + public func updateFolderCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) { [weak self] in + self?.vm.closeTextField() + } + } + + public func updateFolderPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + guard let name = alertVC.inputText, !name.isEmpty else { return } + vm.update(name: name) + + if let errorMessage = vm.errorMessage { + alertVC.setErrorMessage(errorMessage) + } else { + alertVC.dismiss(animated: true) { [weak self] in + self?.vm.closeTextField() + } + } + } +} + +#if DEBUG + #Preview("개인 폴더") { + UINavigationController( + rootViewController: FolderViewController( + vm: .preview() + ) + ) + } +#endif diff --git a/Presentation/Sources/View/Main/Cell/MainCategoryContentConfiguration.swift b/Presentation/Sources/View/Main/Cell/MainCategoryContentConfiguration.swift new file mode 100644 index 00000000..54358aaf --- /dev/null +++ b/Presentation/Sources/View/Main/Cell/MainCategoryContentConfiguration.swift @@ -0,0 +1,158 @@ +import UIKit + +// MARK: - Content Configuration + +struct MainCategoryContentConfiguration: UIContentConfiguration { + var imageName: String = "" + var title: String = "" + var totalCount: Int = 0 + var isSelected: Bool = false + var didScroll: Bool = false + + func makeContentView() -> UIView & UIContentView { + MainCategoryContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> MainCategoryContentConfiguration { + guard let state = state as? UICellConfigurationState else { return self } + var updatedConfig = self + updatedConfig.isSelected = state.isSelected + return updatedConfig + } +} + +// MARK: - Content View + +final class MainCategoryContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + /// Components + private let container: UIStackView = { + let c = UIStackView() + c.translatesAutoresizingMaskIntoConstraints = false + c.axis = .vertical + c.alignment = .fill + c.spacing = 0 + c.isLayoutMarginsRelativeArrangement = true + c.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + c.layer.cornerRadius = 20 + c.layer.borderWidth = 1.0 + c.layer.borderColor = UIColor.gray600.cgColor + return c + }() + + private let imageRow: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 0 + return stackView + }() + + private let imageSpacer = UIView() + + private let imageView: UIImageView = { + let img = UIImageView() + img.translatesAutoresizingMaskIntoConstraints = false + img.contentMode = .scaleAspectFit + img.tintColor = UIColor.gray600 + return img + }() + + private let titleLabel: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.textColor = UIColor.gray600 + t.numberOfLines = 1 + return t + }() + + private let countView: UILabel = { + let c = UILabel() + c.translatesAutoresizingMaskIntoConstraints = false + c.textColor = UIColor.gray750 + return c + }() + + /// Init + init(configuration: MainCategoryContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setup() + apply(configuration: configuration) + } + + required init?(coder: NSCoder) { + nil + } + + /// Setup & Constraints + private func setup() { + addSubview(container) + container.addArrangedSubview(imageRow) + imageRow.addArrangedSubview(imageView) + imageRow.addArrangedSubview(imageSpacer) + container.addArrangedSubview(titleLabel) + container.addArrangedSubview(countView) + container.setCustomSpacing(6, after: imageRow) + container.setCustomSpacing(16, after: titleLabel) + + let bottomConstraint = container.bottomAnchor.constraint(equalTo: bottomAnchor) + bottomConstraint.priority = .defaultHigh + + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomConstraint + ]) + + let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 20) + let heightConstraint = imageView.heightAnchor.constraint(equalToConstant: 20) + widthConstraint.priority = .init(999) + heightConstraint.priority = .init(999) + + NSLayoutConstraint.activate([ + widthConstraint, + heightConstraint + ]) + + imageRow.setContentHuggingPriority(.init(999), for: .horizontal) + imageRow.setContentCompressionResistancePriority(.init(999), for: .horizontal) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + func setSelectedState(_ isSelected: Bool, totalCount: Int) { + UIView.animate(withDuration: 0.2) { + self.container.layer.borderColor = isSelected ? UIColor.point900.cgColor : UIColor.gray600.cgColor + self.countView.setTypography(text: String(totalCount), style: isSelected ? .title3 : .label) + self.titleLabel.textColor = isSelected ? UIColor.gray950 : UIColor.gray600 + self.imageView.tintColor = isSelected ? UIColor.gray950 : UIColor.gray600 + } + } + + func setDidScrollState(_ didScroll: Bool) { + container.axis = didScroll ? .horizontal : .vertical + container.alignment = didScroll ? .center : .fill + container.spacing = didScroll ? 6 : 0 + container.layoutMargins = didScroll + ? UIEdgeInsets(top: 8, left: 14, bottom: 8, right: 14) + : UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + container.layer.cornerRadius = didScroll ? 18 : 20 + imageSpacer.isHidden = didScroll + countView.isHidden = didScroll + } + + /// Apply + private func apply(configuration: UIContentConfiguration) { + guard let configuration = configuration as? MainCategoryContentConfiguration else { return } + imageView.image = UIImage(systemName: configuration.imageName) + titleLabel.setTypography(text: configuration.title, style: .subtitle2) + countView.setTypography(text: String(configuration.totalCount), style: .label) + setSelectedState(configuration.isSelected, totalCount: configuration.totalCount) + setDidScrollState(configuration.didScroll) + } +} diff --git a/Presentation/Sources/View/Main/Cell/MainCategoryHeaderView.swift b/Presentation/Sources/View/Main/Cell/MainCategoryHeaderView.swift new file mode 100644 index 00000000..8f87ee64 --- /dev/null +++ b/Presentation/Sources/View/Main/Cell/MainCategoryHeaderView.swift @@ -0,0 +1,202 @@ +import UIKit + +final class MainCategoryHeaderView: UICollectionReusableView { + // MARK: - Type + + typealias DataSource = UICollectionViewDiffableDataSource + typealias SnapShot = NSDiffableDataSourceSnapshot + static let elementKind = "MainCategoryHeaderView" + private static let cellReuseIdentifier = "MainCategoryHeaderCell" + private enum LayoutConstant { + static let expandedItemSize = CGSize(width: 92, height: 120) + static let collapsedItemHeight: CGFloat = 38 + static let collapsedMinimumWidth: CGFloat = 116 + } + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 8 + layout.minimumInteritemSpacing = 8 + + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.delegate = self + return view + }() + + private var dataSource: DataSource! + + // MARK: - State + + private var categories: [CategoryToggle] = [] + private var selectedIndex: Int = 0 + private var didScroll: Bool = false + private var onSelect: ((Int) -> Void)? + private var heightConstraint: NSLayoutConstraint! + + // MARK: - Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupDataSource() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + func configure( + categories: [CategoryToggle], + selectedIndex: Int, + didScroll: Bool, + onSelect: @escaping (Int) -> Void + ) { + self.categories = categories + self.selectedIndex = selectedIndex + self.onSelect = onSelect + updateScrollState(didScroll) + updateDataSource() + } + + func updateScrollState(_ val: Bool) { + guard didScroll != val else { return } + didScroll = val + heightConstraint.constant = didScroll + ? LayoutConstant.collapsedItemHeight + : LayoutConstant.expandedItemSize.height + updateVisibleCells() + collectionView.collectionViewLayout.invalidateLayout() + } + + private func setupUI() { + backgroundColor = UIColor.gray50 + addSubview(collectionView) + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: Self.cellReuseIdentifier) + heightConstraint = collectionView.heightAnchor.constraint( + equalToConstant: LayoutConstant.expandedItemSize.height + ) + heightConstraint.priority = .defaultHigh + + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: topAnchor), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), + heightConstraint + ]) + } + + private func setupDataSource() { + createDataSource() + updateDataSource() + } + + private func applySelection(animated: Bool) { + let indexPath = IndexPath(item: selectedIndex, section: 0) + guard categories.indices.contains(selectedIndex) else { return } + collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: []) + updateVisibleCells() + } + + override func prepareForReuse() { + super.prepareForReuse() + } +} + +// MARK: - DataSource + +fileprivate extension MainCategoryHeaderView { + func createDataSource() { + dataSource = DataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self else { return nil } + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: Self.cellReuseIdentifier, + for: indexPath + ) + cell.backgroundConfiguration = .clear() + cell.contentConfiguration = makeContentConfiguration( + for: item, + isSelected: indexPath.item == selectedIndex, + didScroll: didScroll + ) + return cell + } + } + + func updateDataSource() { + var snapshot = SnapShot() + snapshot.appendSections([0]) + snapshot.appendItems(categories, toSection: 0) + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.applySelection(animated: false) + } + } +} + +// MARK: - Helper + +fileprivate extension MainCategoryHeaderView { + func updateVisibleCells() { + for visibleCell in collectionView.visibleCells { + guard let itemIndexPath = collectionView.indexPath(for: visibleCell), + let item = dataSource.itemIdentifier(for: itemIndexPath) + else { continue } + + visibleCell.contentConfiguration = makeContentConfiguration( + for: item, + isSelected: itemIndexPath.item == selectedIndex, + didScroll: didScroll + ) + } + } + + func makeContentConfiguration( + for category: CategoryToggle, + isSelected: Bool, + didScroll: Bool + ) -> MainCategoryContentConfiguration { + MainCategoryContentConfiguration( + imageName: category.imageName, + title: category.title, + totalCount: category.items.count, + isSelected: isSelected, + didScroll: didScroll + ) + } +} + +// MARK: - Delegate + +extension MainCategoryHeaderView: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + selectedIndex = indexPath.item + applySelection(animated: true) + onSelect?(indexPath.item) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + return .init( + width: didScroll ? LayoutConstant.collapsedMinimumWidth : LayoutConstant.expandedItemSize.width, + height: didScroll ? LayoutConstant.collapsedItemHeight : LayoutConstant.expandedItemSize.height + ) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetForSectionAt section: Int + ) -> UIEdgeInsets { + UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + } +} diff --git a/Presentation/Sources/View/Main/Cell/MainSectionHeaderView.swift b/Presentation/Sources/View/Main/Cell/MainSectionHeaderView.swift new file mode 100644 index 00000000..a7475826 --- /dev/null +++ b/Presentation/Sources/View/Main/Cell/MainSectionHeaderView.swift @@ -0,0 +1,36 @@ +import UIKit + +final class MainSectionHeaderView: UICollectionReusableView { + static let reuseIdentifier = "MainSectionHeaderView" + + private let titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = UIColor.gray950 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + func configure(title: String) { + titleLabel.setTypography(text: title, style: .title3) + } + + private func setupUI() { + addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 32), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} diff --git a/Presentation/Sources/View/Main/MainViewController.swift b/Presentation/Sources/View/Main/MainViewController.swift new file mode 100644 index 00000000..3e0d22b9 --- /dev/null +++ b/Presentation/Sources/View/Main/MainViewController.swift @@ -0,0 +1,501 @@ +import Core +import Domain +import SwiftUI +import UIKit + +public final class MainViewController: ViewController { + // MARK: - Type + + typealias CategoryHeaderRegistration = UICollectionView.SupplementaryRegistration + typealias ListCellRegistration = UICollectionView.CellRegistration + typealias EmptyCellRegistration = UICollectionView.CellRegistration + typealias SectionHeaderRegistration = UICollectionView.SupplementaryRegistration + typealias DataSource = UICollectionViewDiffableDataSource + typealias SnapShot = NSDiffableDataSourceSnapshot + + // MARK: - View Model + + private let vm: MainViewModel + + // MARK: - Initialize + + public init(vm: MainViewModel) { + self.vm = vm + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Component + + private let navTitle: UILabel = { + let n = UILabel() + n.translatesAutoresizingMaskIntoConstraints = false + n.setTypography(text: "차곡", style: .header2) + n.textColor = UIColor.gray950 + return n + }() + + private let collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + let c = UICollectionView(frame: .zero, collectionViewLayout: layout) + c.translatesAutoresizingMaskIntoConstraints = false + c.backgroundColor = .clear + return c + }() + + private lazy var searchItem: UIBarButtonItem = { + let search = UIBarButtonItem() + search.image = UIImage(systemName: "magnifyingglass") + search.menu = nil + search.primaryAction = UIAction { [weak self] _ in + self?.vm.pushSearchView() + } + return search + }() + + private lazy var settingItem: UIBarButtonItem = .init( + image: UIImage(systemName: "gearshape"), + primaryAction: UIAction { [weak self] _ in + self?.vm.pushSettingView() + } + ) + + private let floatingButton: GlassButton = .floating( + image: .init(imageName: "microphone", type: .system) + ) + + var dataSource: DataSource! + + // MARK: LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + setup() + setupCollectionView() + setupfloatingButton() + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + vm.updateRecentCategory() + vm.updateVoiceNoteCategory() + vm.updateMyFolderCategory() + vm.updateTrashCategory() + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + vm.cancelObservations() + } + + override public func updateProperties() { + super.updateProperties() + updateNavigationBarAppearance(isTransparent: false) + updateDataSource() + } + + // MARK: Setup + + private func setup() { + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: navTitle) + navigationItem.rightBarButtonItems = [settingItem, searchItem] + navigationItem.leftBarButtonItem?.hidesSharedBackground = true + navigationItem.rightBarButtonItems?.forEach { $0.hidesSharedBackground = true + } + } + + private func setupCollectionView() { + view.addSubview(collectionView) + view.addSubview(floatingButton) + collectionViewConstraint() + collectionView.showsVerticalScrollIndicator = false + collectionView.showsHorizontalScrollIndicator = false + collectionView.delegate = self + collectionView.setCollectionViewLayout( + createLayout(), + animated: false + ) + + let listRegistration = ListCellRegistration { cell, _, item in + cell.backgroundConfiguration = .clear() + cell.contentConfiguration = UIHostingConfiguration { + switch item { + case .folder(let data): + FolderCardView(folder: data) + case .voiceNote(let data): + VoiceNoteCardView( + voiceNote: data, + completeAction: { [weak self] in + self?.vm.pushVoiceNoteView(voiceNote: data) + } + ) + } + } + .margins(.all, 0) + } + + let emptyRegistration = EmptyCellRegistration { cell, _, _ in + cell.backgroundConfiguration = .clear() + cell.contentConfiguration = EmptyContentConfiguration( + message: "아직 녹음된 기록이 없습니다.\n녹음 버튼을 눌러 첫 기록을 시작해보세요." + ) + } + + let categoryHeaderRegistration = CategoryHeaderRegistration( + elementKind: MainCategoryHeaderView.elementKind + ) { [weak self] header, _, _ in + guard let self else { return } + header.configure( + categories: vm.categoryData, + selectedIndex: vm.selectedCategoryIndex, + didScroll: vm.didScroll + ) { [weak self] selectedIndex in + self?.selectCategory(at: selectedIndex) + } + } + + let sectionHeaderRegistration = SectionHeaderRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { header, _, indexPath in + guard let section = self.dataSource.sectionIdentifier(for: indexPath.section), + case .groupedList(let group) = section else { return } + header.configure(title: group.title) + } + + setupDataSource( + listRegistration: listRegistration, + emptyRegistration: emptyRegistration, + categoryHeaderRegistration: categoryHeaderRegistration, + sectionHeaderRegistration: sectionHeaderRegistration + ) + } + + private func setupfloatingButton() { + floatingButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + vm.handleRecordButtonTap( + alertAction: { + vm.alertCoordinator?.presentAlert(environment: .micPermissionRequired, delegate: self) + } + ) + }, for: .touchUpInside) + + NSLayoutConstraint.activate([ + floatingButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + floatingButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -42) + ]) + } + + private func collectionViewConstraint() { + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func updateInteractionForAlert(isPresented: Bool) { + collectionView.isUserInteractionEnabled = !isPresented + navigationItem.leftBarButtonItem?.isEnabled = !isPresented + navigationItem.rightBarButtonItem?.isEnabled = !isPresented + navigationItem.rightBarButtonItems?.forEach { $0.isEnabled = !isPresented } + } + + private func openAppSettings() { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(settingsURL) else { return } + UIApplication.shared.open(settingsURL) + } +} + +// MARK: - Collection view Layout Custom + +extension MainViewController { + private func createLayout() -> UICollectionViewCompositionalLayout { + let sectionProvider: UICollectionViewCompositionalLayoutSectionProvider = { [weak self] sectionIndex, _ in + guard let self, + let section = dataSource.sectionIdentifier(for: sectionIndex) + else { + return self?.emptySection() + } + + switch section { + case .list: + return createSection( + itemWidth: .fractionalWidth(1.0), + itemHeight: .estimated(120), + groupWidth: .fractionalWidth(1.0), + groupHeight: .estimated(120), + interGroupSpacing: 8, + contentInsets: .init(top: 32, leading: 20, bottom: 0, trailing: 20) + ) + case .groupedList: + return createSection( + itemWidth: .fractionalWidth(1.0), + itemHeight: .estimated(120), + groupWidth: .fractionalWidth(1.0), + groupHeight: .estimated(120), + interGroupSpacing: 8, + contentInsets: .init(top: 0, leading: 20, bottom: 0, trailing: 20), + headerHeight: 72 + ) + case .emptyList: + return createSection( + itemWidth: .fractionalWidth(1.0), + itemHeight: .estimated(300), + groupWidth: .fractionalWidth(1.0), + groupHeight: .estimated(300) + ) + } + } + + let configuration = UICollectionViewCompositionalLayoutConfiguration() + let categoryHeader = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(120) + ), + elementKind: MainCategoryHeaderView.elementKind, + alignment: .top + ) + categoryHeader.pinToVisibleBounds = true + categoryHeader.zIndex = 10 + configuration.boundarySupplementaryItems = [categoryHeader] + + return UICollectionViewCompositionalLayout( + sectionProvider: sectionProvider, + configuration: configuration + ) + } + + private func group(for item: ContentItem, now: Date = .now) -> MainListDateGroup { + let calendar = Calendar.current + let date = item.createdAt + + if calendar.isDate(date, inSameDayAs: now) { + return .today + } + + let startOfToday = calendar.startOfDay(for: now) + let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: startOfToday) ?? startOfToday + if date >= sevenDaysAgo { + return .recentSevenDays + } + + return .older + } + + private func groupedItems(_ items: [ContentItem]) -> [(section: MainSection, items: [MainCellItem])] { + let grouped = Dictionary(grouping: items) { group(for: $0) } + + return MainListDateGroup.allCases.compactMap { group in + guard let items = grouped[group], !items.isEmpty else { return nil } + let sortedItems = items.sorted { $0.createdAt > $1.createdAt } + return (.groupedList(group), sortedItems.map(MainCellItem.list)) + } + } + + private func createSection( + itemWidth: NSCollectionLayoutDimension, + itemHeight: NSCollectionLayoutDimension, + groupWidth: NSCollectionLayoutDimension, + groupHeight: NSCollectionLayoutDimension, + interItemSpacing: NSCollectionLayoutSpacing = .fixed(0), + interGroupSpacing: CGFloat = 0.0, + contentInsets: NSDirectionalEdgeInsets = .zero, + headerHeight: CGFloat? = nil, + scrollBehavior: UICollectionLayoutSectionOrthogonalScrollingBehavior = .none + ) -> NSCollectionLayoutSection { + let itemSize: NSCollectionLayoutSize = .init( + widthDimension: itemWidth, heightDimension: itemHeight + ) + let groupSize: NSCollectionLayoutSize = .init( + widthDimension: groupWidth, heightDimension: groupHeight + ) + + let item: NSCollectionLayoutItem = .init(layoutSize: itemSize) + let group: NSCollectionLayoutGroup = .vertical(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = interItemSpacing + let section: NSCollectionLayoutSection = .init(group: group) + section.interGroupSpacing = interGroupSpacing + section.contentInsets = contentInsets + section.orthogonalScrollingBehavior = scrollBehavior + + if let headerHeight { + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(headerHeight) + ) + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + header.contentInsets = .init(top: 0, leading: 4, bottom: 16, trailing: 4) + section.boundarySupplementaryItems = [header] + } + + return section + } + + private func emptySection() -> NSCollectionLayoutSection { + createSection( + itemWidth: .fractionalWidth(0), + itemHeight: .fractionalHeight(0), + groupWidth: .fractionalWidth(0), + groupHeight: .fractionalHeight(0) + ) + } +} + +// MARK: - setup DataSource + +extension MainViewController { + private func setupDataSource( + listRegistration: ListCellRegistration, + emptyRegistration: EmptyCellRegistration, + categoryHeaderRegistration: CategoryHeaderRegistration, + sectionHeaderRegistration: SectionHeaderRegistration + ) { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .list(let item): + return collectionView.dequeueConfiguredReusableCell( + using: listRegistration, + for: indexPath, + item: item + ) + case .emptyList: + return collectionView.dequeueConfiguredReusableCell( + using: emptyRegistration, + for: indexPath, + item: itemIdentifier + ) + } + } + ) + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + if kind == MainCategoryHeaderView.elementKind { + return collectionView.dequeueConfiguredReusableSupplementary( + using: categoryHeaderRegistration, + for: indexPath + ) + } + + guard kind == UICollectionView.elementKindSectionHeader else { return nil } + return collectionView.dequeueConfiguredReusableSupplementary( + using: sectionHeaderRegistration, + for: indexPath + ) + } + } + + private func updateDataSource() { + var snapshot = SnapShot() + + let selectedCategory = vm.categoryData[vm.selectedCategoryIndex] + let items = selectedCategory.items + if items.isEmpty { + snapshot.appendSections([.emptyList]) + snapshot.appendItems([.emptyList], toSection: .emptyList) + } else if vm.shouldGroupSelectedCategory { + for group in groupedItems(items) { + snapshot.appendSections([group.section]) + snapshot.appendItems(group.items, toSection: group.section) + } + } else { + snapshot.appendSections([.list]) + let cellItems = items.map(MainCellItem.list) + snapshot.appendItems(cellItems, toSection: .list) + } + + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.updateVisibleCategoryHeader() + } + } + + private func selectCategory(at index: Int) { + vm.setSelectedCategoryIndex(indexPath: IndexPath(item: index, section: 0)) + updateDataSource() + } + + private func updateVisibleCategoryHeader() { + guard let header = collectionView.visibleSupplementaryViews(ofKind: MainCategoryHeaderView.elementKind) + .first as? MainCategoryHeaderView else { return } + + header.configure( + categories: vm.categoryData, + selectedIndex: vm.selectedCategoryIndex, + didScroll: vm.didScroll + ) { [weak self] selectedIndex in + self?.selectCategory(at: selectedIndex) + } + } +} + +// MARK: - Delegate + +extension MainViewController: UICollectionViewDelegate, ChaGokAlertButtonTappedDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + let didScroll = offsetY > 0 + + guard vm.didScroll != didScroll else { return } + vm.setDidScroll(didScroll) + guard let header = collectionView.visibleSupplementaryViews(ofKind: MainCategoryHeaderView.elementKind) + .first as? MainCategoryHeaderView else { return } + header.updateScrollState(didScroll) + collectionView.collectionViewLayout.invalidateLayout() + } + + public func micPermissionCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) + } + + public func micPermissionPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + openAppSettings() + alertVC.dismiss(animated: true) + } +} + +#if DEBUG + #Preview("최근 기록") { + UINavigationController( + rootViewController: MainViewController( + vm: .preview(selectedCategoryIndex: 0) + ) + ) + } + + #Preview("기본 폴더") { + UINavigationController( + rootViewController: MainViewController( + vm: .preview(selectedCategoryIndex: 1) + ) + ) + } + + #Preview("개인 폴더") { + UINavigationController( + rootViewController: MainViewController( + vm: .preview(selectedCategoryIndex: 2) + ) + ) + } + + #Preview("휴지통") { + UINavigationController( + rootViewController: MainViewController( + vm: .preview(selectedCategoryIndex: 3) + ) + ) + } +#endif diff --git a/Presentation/Sources/View/MoveVoiceNote/FolderListCell.swift b/Presentation/Sources/View/MoveVoiceNote/FolderListCell.swift new file mode 100644 index 00000000..6c57d355 --- /dev/null +++ b/Presentation/Sources/View/MoveVoiceNote/FolderListCell.swift @@ -0,0 +1,127 @@ +import UIKit + +public struct FolderCellContentConfiguration: UIContentConfiguration { + let title: String + let number: Int + var isSelected: Bool = false + + public func makeContentView() -> any UIView & UIContentView { + FolderCellContentView(configuration: self) + } + + public func updated(for state: any UIConfigurationState) -> FolderCellContentConfiguration { + var updated = self + if let cellState = state as? UICellConfigurationState { + updated.isSelected = cellState.isSelected + } + return updated + } +} + +final class FolderCellContentView: UIView, UIContentView { + private let iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .folder + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.setTypography(style: .body2) + label.textColor = .gray800 + return label + }() + + private let countLabel: UILabel = { + let label = UILabel() + label.setTypography(style: .body2) + label.textColor = .gray750 + return label + }() + + private let backgroundView: UIVisualEffectView = { + let glassEffect = UIGlassEffect() + let view = UIVisualEffectView(effect: glassEffect) + view.layer.cornerRadius = 20 + view.layer.masksToBounds = true + return view + }() + + var configuration: any UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + init(configuration: any UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setupUI() + apply(configuration: configuration) + } + + required init?(coder: NSCoder) { + nil + } + + func setupUI() { + for view in [backgroundView, iconImageView, titleLabel, countLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + + NSLayoutConstraint.activate([ + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + + iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + iconImageView.topAnchor.constraint(equalTo: topAnchor, constant: 16), + iconImageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), + iconImageView.widthAnchor.constraint(equalToConstant: 20), + iconImageView.heightAnchor.constraint(equalToConstant: 20), + + titleLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 8), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: countLabel.leadingAnchor, constant: -8), + + countLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + countLabel.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + func apply(configuration: any UIContentConfiguration) { + guard let configuration = configuration as? FolderCellContentConfiguration else { return } + titleLabel.text = configuration.title + countLabel.text = configuration.number.formatted() + backgroundView.layer.borderWidth = configuration.isSelected ? 1 : 0 + backgroundView.layer.borderColor = configuration.isSelected + ? UIColor(red: 0xd9 / 255, green: 0xb5 / 255, blue: 0xff / 255, alpha: 1).cgColor + : UIColor.clear.cgColor + } +} + +#Preview { + let normalConfig = FolderCellContentConfiguration(title: "새 폴더", number: 0) + let selectedConfig = FolderCellContentConfiguration(title: "선택된 폴더", number: 3, isSelected: true) + + let normalCell = FolderCellContentView(configuration: normalConfig) + let selectedCell = FolderCellContentView(configuration: selectedConfig) + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + [normalCell, selectedCell].forEach { stack.addArrangedSubview($0) } + + let container = UIView() + container.backgroundColor = .gray100 + container.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + + return container +} diff --git a/Presentation/Sources/View/MoveVoiceNote/MoveFolderListViewController.swift b/Presentation/Sources/View/MoveVoiceNote/MoveFolderListViewController.swift new file mode 100644 index 00000000..bb48cc7e --- /dev/null +++ b/Presentation/Sources/View/MoveVoiceNote/MoveFolderListViewController.swift @@ -0,0 +1,164 @@ +import Domain +import UIKit + +public final class MoveFolderListViewController: UIViewController, Alertable { + private let viewModel: MoveFolderListViewModel + + public init(viewModel: MoveFolderListViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + private typealias Item = Folder + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private enum Section { case main } + + private lazy var dataSource = makeDataSource() + + private lazy var leftTitleLable: UILabel = { + let label = UILabel() + label.setTypography(text: viewModel.state.leftTitle, style: .title3) + label.textColor = .gray950 + + return label + }() + + private lazy var addFolderButton: UIButton = { + var configuration = UIButton.Configuration.plain() + configuration.title = viewModel.state.addFolderButtonTitle + configuration.image = UIImage(systemName: "plus") + configuration.baseForegroundColor = .gray800 + configuration.contentInsets = .zero + + let button = UIButton(configuration: configuration) + button.addAction(UIAction { [weak self] _ in + self?.viewModel.send(.view(.addFolderButtonTapped)) + }, for: .touchUpInside) + return button + }() + + private lazy var titleStack: UIStackView = { + let stackView = UIStackView() + [leftTitleLable, addFolderButton].forEach { stackView.addArrangedSubview($0) } + stackView.distribution = .equalSpacing + + return stackView + }() + + private lazy var folderListView: UICollectionView = { + let layout = UICollectionViewCompositionalLayout { _, environment in + var configuration = UICollectionLayoutListConfiguration(appearance: .plain) + configuration.backgroundColor = .clear + let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: environment) + section.interGroupSpacing = 8 + return section + } + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.delegate = self + + return collectionView + }() + + private lazy var moveButton: UIButton = { + var configuration = UIButton.Configuration.filled() + configuration.contentInsets.top = 16 + configuration.contentInsets.bottom = 16 + configuration.title = viewModel.state.moveButtonTitle + configuration.background.cornerRadius = 20 + let button = UIButton(configuration: configuration) + button.addAction(UIAction(handler: { [weak self] _ in + self?.viewModel.send(.view(.moveButtonTapped)) + }), for: .touchUpInside) + return button + }() + + override public func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.send(.view(.onAppear)) + } + + override public func updateProperties() { + super.updateProperties() + applySnapshot() + + let isEnabled = viewModel.state.isMoveButtonEnabled + moveButton.isEnabled = isEnabled + moveButton.configuration?.baseBackgroundColor = isEnabled ? .point600 : .gray300 + moveButton.configuration?.baseForegroundColor = isEnabled ? .gray950 : .gray600 + + if let message = viewModel.state.errorMessage { + viewModel.send(.view(.errorMessageDismissed)) + showAlert(message: message) + } + } + + private func makeDataSource() -> DataSource { + let cellRegistration = UICollectionView + .CellRegistration { [weak self] cell, _, item in + guard let self else { return } + let isSelected = viewModel.state.selectedFolder?.id == item.id + cell.contentConfiguration = FolderCellContentConfiguration( + title: item.name, + number: item.voiceNoteIDs.count, + isSelected: isSelected + ) + } + + return DataSource(collectionView: folderListView) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + } + } + + private func applySnapshot() { + var snapshot = Snapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(viewModel.state.folders) + dataSource.apply(snapshot) + } + + private func setupUI() { + view.backgroundColor = .gray100 + + for view in [titleStack, folderListView, moveButton] { + view.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(view) + } + + NSLayoutConstraint.activate([ + titleStack.topAnchor.constraint(equalTo: view.topAnchor, constant: 44), + titleStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + titleStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + titleStack.heightAnchor.constraint(equalToConstant: 24), + + folderListView.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 24), + folderListView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + folderListView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + moveButton.topAnchor.constraint(equalTo: folderListView.bottomAnchor, constant: 24), + moveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + moveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + moveButton.heightAnchor.constraint(equalToConstant: 54), + moveButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -74) + ]) + } +} + +extension MoveFolderListViewController: UICollectionViewDelegate { + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let folder = dataSource.itemIdentifier(for: indexPath) else { return } + viewModel.send(.view(.folderSelected(folder))) + } +} diff --git a/Presentation/Sources/View/MoveVoiceNote/NewFolderViewController.swift b/Presentation/Sources/View/MoveVoiceNote/NewFolderViewController.swift new file mode 100644 index 00000000..e2a16710 --- /dev/null +++ b/Presentation/Sources/View/MoveVoiceNote/NewFolderViewController.swift @@ -0,0 +1,146 @@ +import UIKit + +public final class NewFolderViewController: UIViewController, Alertable { + private let viewModel: NewFolderViewModel + + public init(viewModel: NewFolderViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.setTypography(text: "새 폴더 만들기", style: .title3) + label.textColor = .gray950 + return label + }() + + private lazy var folderNameTextField: UITextField = { + let textField = UITextField() + textField.borderStyle = .none + textField.textColor = .gray950 + textField.attributedPlaceholder = NSAttributedString( + string: "사용자가 설정하는 폴더이름", + attributes: [.foregroundColor: UIColor.gray600] + ) + return textField + }() + + private lazy var textFieldContainer: UIView = { + let view = UIView() + view.backgroundColor = .gray200 + view.layer.cornerRadius = 8 + return view + }() + + private lazy var cancelButton: UIButton = { + var configuration = UIButton.Configuration.filled() + configuration.title = "취소" + configuration.background.cornerRadius = 20 + configuration.baseBackgroundColor = .gray300 + configuration.baseForegroundColor = .gray950 + configuration.contentInsets = .zero + return UIButton(configuration: configuration) + }() + + private lazy var createButton: UIButton = { + var configuration = UIButton.Configuration.filled() + configuration.title = "만들기" + configuration.background.cornerRadius = 20 + configuration.baseBackgroundColor = .point600 + configuration.baseForegroundColor = .gray950 + configuration.contentInsets = .zero + return UIButton(configuration: configuration) + }() + + private let spacerView = UIView() + + private lazy var buttonStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [cancelButton, createButton]) + stack.axis = .horizontal + stack.spacing = 8 + stack.distribution = .fillEqually + return stack + }() + + override public var preferredContentSize: CGSize { + get { + view.layoutIfNeeded() + let height = view.systemLayoutSizeFitting( + CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + return CGSize(width: view.bounds.width, height: height) + } + set { super.preferredContentSize = newValue } + } + + override public func viewDidLoad() { + super.viewDidLoad() + setupUI() + addActions() + } + + override public func updateProperties() { + super.updateProperties() + guard let message = viewModel.state.errorMessage else { return } + viewModel.clearErrorMessage() + showAlert(message: message) + } + + private func setupUI() { + view.backgroundColor = .gray100 + + textFieldContainer.addSubview(folderNameTextField) + folderNameTextField.translatesAutoresizingMaskIntoConstraints = false + + for subview in [titleLabel, textFieldContainer, buttonStack, spacerView] { + subview.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(subview) + } + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 44), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + titleLabel.heightAnchor.constraint(equalToConstant: 24), + + textFieldContainer.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 26), + textFieldContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + textFieldContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + textFieldContainer.heightAnchor.constraint(equalToConstant: 40), + + folderNameTextField.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12), + folderNameTextField.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12), + folderNameTextField.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor), + + buttonStack.topAnchor.constraint(equalTo: textFieldContainer.bottomAnchor, constant: 20), + buttonStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + buttonStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + buttonStack.heightAnchor.constraint(equalToConstant: 46), + + spacerView.topAnchor.constraint(equalTo: buttonStack.bottomAnchor), + spacerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + spacerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + spacerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func addActions() { + cancelButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + viewModel.send(.view(.cancelButtonTapped)) + }, for: .touchUpInside) + + createButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + viewModel.send(.view(.createButtonTapped(name: folderNameTextField.text ?? ""))) + }, for: .touchUpInside) + } +} diff --git a/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift new file mode 100644 index 00000000..74acff1a --- /dev/null +++ b/Presentation/Sources/View/OnBoarding/OnBoardingViewController.swift @@ -0,0 +1,260 @@ +import Core +import Domain +import Foundation +import UIKit + +public final class OnBoardingViewController: ViewController { + // MARK: - State + + private let vm: OnBoardingViewModel + private var didSetupUI = false + + public init(vm: OnBoardingViewModel) { + self.vm = vm + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Component + + private lazy var pagenation: Pagenation = .init( + currentIndex: vm.currentStepIndex, + maxIndex: vm.steps.count + ) + + private lazy var pagingView: OnBoardingPagingView = .init(pages: createPages()) + + private lazy var primaryButton: GlassButton = .default(vm.primaryButtonTitle) + + private lazy var secondButton: UIButton = { + let btn = UIButton() + btn.translatesAutoresizingMaskIntoConstraints = false + var config: UIButton.Configuration = .plain() + config.title = vm.secondButtonTitle + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = Typography.body3.font + return outgoing + } + config.baseForegroundColor = UIColor.gray750 + config.baseBackgroundColor = .clear + btn.configuration = config + return btn + }() + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + Task { [weak self] in + guard let self else { return } + await vm.checkModelSupport() + setup() + setupPagenation() + setupCard() + setupButtons() + didSetupUI = true + setNeedsUpdateProperties() + } + } + + override public func updateProperties() { + super.updateProperties() + guard didSetupUI else { return } + + // 버튼 상태 업데이트 + primaryButton.configuration?.title = vm.primaryButtonTitle + primaryButton.isUserInteractionEnabled = vm.isPrimaryButtonEnabled + secondButton.configuration?.title = vm.secondButtonTitle + secondButton.isUserInteractionEnabled = vm.isSecondButtonEnabled + primaryButton.configuration?.baseBackgroundColor = vm + .isPrimaryButtonBgColor ? (vm.isPrimaryButtonEnabled ? UIColor.point600 : UIColor.gray600) : UIColor + .point200 + .withAlphaComponent(Constant.backgroundOpacity) + primaryButton.configuration?.baseForegroundColor = UIColor.gray900 + // paginView + pagingView.isScrollEnabled = vm.scrollEnabled + // pagenation 업데이트 + pagenation.currentIndex = vm.currentStepIndex + } + + // MARK: - Set up + + private func setup() { + view.backgroundColor = UIColor.gray50 + // scroll delegate + pagingView.delegate = self + // 모든 뷰를 먼저 계층 구조에 추가 (제약 조건 충돌 방지) + view.addSubview(pagenation) + view.addSubview(pagingView) + view.addSubview(primaryButton) + view.addSubview(secondButton) + } + + private func setupPagenation() { + setupPagenationConstraint() + } + + private func setupCard() { + setupCardConstraint() + } + + private func setupButtons() { + setupButtonConstraint() + // 버튼은 스크롤만 시킴 → 상태 업데이트는 delegate에서 처리 + primaryButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + vm.primaryButtonAction { index in + let offsetX = CGFloat(index) * pagingView.frame.width + pagingView.setContentOffset(CGPoint(x: offsetX, y: 0), animated: true) + } + }, for: .touchUpInside + ) + + secondButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + vm.secondButtonAction { index in + let offsetX = CGFloat(index) * pagingView.frame.width + pagingView.setContentOffset(CGPoint(x: offsetX, y: 0), animated: true) + } + }, for: .touchUpInside + ) + } + + // MARK: - Constraint + + private func setupCardConstraint() { + NSLayoutConstraint.activate([ + // 페이징 뷰 위치 제약 (페이지네이션과 다음 버튼 사이) + pagingView.topAnchor.constraint( + equalTo: pagenation.bottomAnchor, + constant: Constant.onBoardingPagingViewTopMargin + ), + pagingView.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: Constant.onBoardingHorizontalPadding + ), + pagingView.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -Constant.onBoardingHorizontalPadding + ), + pagingView.bottomAnchor.constraint( + equalTo: primaryButton.topAnchor, + constant: -Constant.onBoardingPagingViewBottomMargin + ) + ]) + } + + private func setupPagenationConstraint() { + NSLayoutConstraint.activate([ + pagenation.topAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.topAnchor, + constant: Constant.onBoardingPaginationTopMargin + ), + pagenation.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: Constant.onBoardingHorizontalPadding + ), + pagenation.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -Constant.onBoardingHorizontalPadding + ) + ]) + } + + private func setupButtonConstraint() { + NSLayoutConstraint.activate([ + secondButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + secondButton.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: Constant.onBoardingButtonHorizontalPadding + ), + secondButton.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -Constant.onBoardingButtonHorizontalPadding + ), + secondButton.heightAnchor.constraint(equalToConstant: Constant.commonButtonHeight), + + primaryButton.bottomAnchor.constraint( + equalTo: secondButton.topAnchor, + constant: -Constant.onBoardingButtonSpacing + ), + primaryButton.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: Constant.onBoardingButtonHorizontalPadding + ), + primaryButton.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -Constant.onBoardingButtonHorizontalPadding + ), + primaryButton.heightAnchor.constraint(equalToConstant: Constant.commonButtonHeight) + ]) + } +} + +// MARK: - Helper Function + +extension OnBoardingViewController { + /// first, second, micPermission 은 OnBoardingCardView로 화면 구성 + /// finish, download 만 다른 컴포넌트 화면을 사용합니다. + private func createPages() -> [UIView] { + vm.steps.map { step in + switch step { + case .first, .second, .micPermission: + let item = step.item + return OnBoardingCardView( + headline: item.headline, + body: item.body, + image: UIImage(named: item.image ?? "", in: Bundle(for: OnBoardingCardView.self), with: nil) + ) + case .download: + let item = step.item + return OnBoardingDownloadView( + headline: item.headline, + body: item.body, + vm: vm + ) + case .finish: + let item = step.item + return OnBoardingFinishView( + headline: item.headline, + body: item.body, + selectedLanguage: vm.language, + onLanguageChanged: { [weak self] lang in + self?.vm.setLanguage(lang) + } + ) + } + } + } +} + +// MARK: - UIScrollViewDelegate + +extension OnBoardingViewController: UIScrollViewDelegate { + /// 사용자가 손으로 스와이프해서 멈췄을 때 + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + let nextStep = Int(round(scrollView.contentOffset.x / scrollView.frame.width)) + vm.syncPageState(nextStep: nextStep) + } + + /// setContentOffset(animated: true)로 코드 스크롤이 끝났을 때 + public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + let nextStep = Int(round(scrollView.contentOffset.x / scrollView.frame.width)) + vm.syncPageState(nextStep: nextStep) + } +} + +#if DEBUG + #Preview { + OnBoardingViewController( + vm: .preview() + ) + } +#endif diff --git a/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift b/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift new file mode 100644 index 00000000..fd64b234 --- /dev/null +++ b/Presentation/Sources/View/Recording/DownloadOnDeviceViewController.swift @@ -0,0 +1,191 @@ +import Foundation +import UIKit + +public final class DownloadOnDeviceViewController: UIViewController, Alertable { + // MARK: Componenet + + private lazy var titleLabel: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.setTypography(text: "음성을 글로 옮길 준비가 필요해요", style: .header2) + t.textColor = UIColor.gray950 + return t + }() + + private lazy var subTitleLabel: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.setTypography( + text: "녹음과 요약을 모두 기기 안에서\n처리하기 위해 AI모델이 필요해요", + style: .subtitle2 + ) + t.numberOfLines = 0 + t.textColor = UIColor.gray800 + return t + }() + + private lazy var subTitle2Label: UILabel = { + let t = UILabel() + t.translatesAutoresizingMaskIntoConstraints = false + t.setTypography( + text: "Wi-Fi연결을 권장하며 몇 분 정도 걸려요.", style: .subtitle2 + ) + t.numberOfLines = 1 + t.textColor = UIColor.gray950 + t.textAlignment = .center + return t + }() + + private var cancelButton: GlassButton = { + let button = GlassButton.close("나중에") + button.isExclusiveTouch = true + return button + }() + + private var primaryButton: GlassButton = .primary("다운로드") + private var cancelDownloadButton: GlassButton = .primary("취소") + private let bottomArea: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var bottomContainer: UIStackView = { + let bottom = UIStackView() + bottom.translatesAutoresizingMaskIntoConstraints = false + bottom.axis = .horizontal + bottom.spacing = 12 + return bottom + }() + + private let infoBox: OnDeviceInfoBox = .init() + + private lazy var downloadModelCard = DownloadModelCard( + symbolName: "externaldrive", + modelName: "Whisper", + style: .default, + storage: vm.status.storage + ) + + // MARK: - Initialize + + private let vm: DownloadOnDeviceViewModel + + public init(vm: DownloadOnDeviceViewModel) { + self.vm = vm + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .gray200 + isModalInPresentation = true + setup() + setupActions() + } + + override public func updateProperties() { + super.updateProperties() + applyDownloadState() + } + + private func setup() { + for item in [titleLabel, subTitleLabel, infoBox, downloadModelCard, subTitle2Label] { + view.addSubview(item) + } + + bottomContainer.addArrangedSubview(cancelButton) + bottomContainer.addArrangedSubview(primaryButton) + view.addSubview(bottomArea) + bottomArea.addSubview(bottomContainer) + bottomArea.addSubview(cancelDownloadButton) + + NSLayoutConstraint.activate([ + // titleLabel + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 54), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + // subTitleLabel + subTitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + subTitleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + subTitleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + // infoBox + infoBox.topAnchor.constraint(equalTo: subTitleLabel.bottomAnchor, constant: 24), + infoBox.leadingAnchor.constraint(equalTo: subTitleLabel.leadingAnchor), + infoBox.trailingAnchor.constraint(equalTo: subTitleLabel.trailingAnchor), + + // downloadModelCard + downloadModelCard.topAnchor.constraint(equalTo: subTitleLabel.bottomAnchor, constant: 24), + downloadModelCard.leadingAnchor.constraint(equalTo: subTitleLabel.leadingAnchor), + downloadModelCard.trailingAnchor.constraint(equalTo: subTitleLabel.trailingAnchor), + + // subTitleLabel2 + subTitle2Label.topAnchor.constraint(equalTo: infoBox.bottomAnchor, constant: 24), + subTitle2Label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + subTitle2Label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + // bottomArea + bottomArea.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + bottomArea.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + bottomArea.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + bottomArea.heightAnchor.constraint(equalToConstant: Constant.commonButtonHeight), + // bottomContainer + bottomContainer.topAnchor.constraint(equalTo: bottomArea.topAnchor), + bottomContainer.leadingAnchor.constraint(equalTo: bottomArea.leadingAnchor), + bottomContainer.trailingAnchor.constraint(equalTo: bottomArea.trailingAnchor), + bottomContainer.bottomAnchor.constraint(equalTo: bottomArea.bottomAnchor), + // cancelDownloadButton + cancelDownloadButton.topAnchor.constraint(equalTo: bottomArea.topAnchor), + cancelDownloadButton.leadingAnchor.constraint(equalTo: bottomArea.leadingAnchor), + cancelDownloadButton.trailingAnchor.constraint(equalTo: bottomArea.trailingAnchor), + cancelDownloadButton.bottomAnchor.constraint(equalTo: bottomArea.bottomAnchor) + ]) + } +} + +// MARK: - Bindings + +private extension DownloadOnDeviceViewController { + func setupActions() { + cancelButton.addAction(UIAction { [weak self] _ in + self?.vm.dismiss() + }, for: .touchUpInside) + + primaryButton.addAction(UIAction { [weak self] _ in + self?.vm.download() + }, for: .touchUpInside) + + cancelDownloadButton.addAction(UIAction { [weak self] _ in + self?.vm.cancelDownload() + }, for: .touchUpInside) + } + + /// 다운로드 진행 상태값을 업데이트 합니다. + func applyDownloadState() { + let isDownloading = vm.isDownloading + let isFailed = vm.status.storage == .failed + let isShowingCard = isDownloading || isFailed + + primaryButton.configuration?.title = isFailed ? "재시도" : "다운로드" + + cancelButton.isExclusiveTouch = isDownloading + bottomContainer.isHidden = isDownloading + cancelDownloadButton.isExclusiveTouch = !isDownloading + cancelDownloadButton.isHidden = !isDownloading + infoBox.isHidden = isShowingCard + downloadModelCard.isHidden = !isShowingCard + if isShowingCard { + downloadModelCard.updateStatus( + vm.status.storage, + errorMessage: vm.errorMessage + ) + } + } +} diff --git a/Presentation/Sources/View/Recording/RecordingViewController.swift b/Presentation/Sources/View/Recording/RecordingViewController.swift new file mode 100644 index 00000000..c5594b69 --- /dev/null +++ b/Presentation/Sources/View/Recording/RecordingViewController.swift @@ -0,0 +1,206 @@ +import Domain +import UIKit + +public final class RecordingViewController: ViewController { + private let viewModel: RecordingViewModel + + // MARK: - UI Components + + private lazy var cancelButton: GlassButton = { + let button = GlassButton() + button.configure( + type: .plain(), + viewModel.state.cancelTitle, + typography: .title2, + backgroundColor: .color(.clear), + foregroundColor: UIColor.gray950 + ) + + return button + }() + + private lazy var completeButton: GlassButton = { + let button = GlassButton() + button.configure( + type: .plain(), + viewModel.state.completeTitle, + typography: .title2, + backgroundColor: .color(.clear), + foregroundColor: UIColor.point800 + ) + + return button + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.textColor = .gray950 + label.setTypography(style: .header2) + + return label + }() + + private let timestampLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.textColor = .gray800 + label.setTypography(style: .subtitle2) + + return label + }() + + private let durationLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.textColor = .gray950 + label.setTypography(style: .header1) + + return label + }() + + private lazy var recordButton: GlassButton = { + let button = GlassButton() + button.setPreferredSymbolConfiguration( + UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold), + forImageIn: .normal + ) + button.configure( + type: .clearGlass(), + nil, + typography: .body1, + image: .init(imageName: recordButtonSymbolName, type: .system) + ) + button.setCapsuleCornerRadius() + button.addAction(UIAction { [weak self] _ in + self?.viewModel.send(.recordButtonTapped) + }, for: .touchUpInside) + return button + }() + + // MARK: - Initialization + + public init(viewModel: RecordingViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + public required init?(coder: NSCoder) { + return nil + } + + // MARK: - View Life Cycle + + override public func viewDidLoad() { + super.viewDidLoad() + setupNavigation() + setupUI() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.send(.viewDidAppear) + } + + override public func updateProperties() { + super.updateProperties() + updateNavigationBarAppearance(isTransparent: true) + chagokBackgroundView.amplitude.value = viewModel.state.amplitude + titleLabel.setTypography(text: viewModel.state.title, style: .header2) + timestampLabel.setTypography(text: viewModel.state.displayStartDate, style: .subtitle2) + durationLabel.setTypography(text: viewModel.state.displayDuration, style: .header1) + recordButton.setImage(UIImage(systemName: recordButtonSymbolName), for: .normal) + } + + // MARK: - Private Methods + + private func setupNavigation() { + viewModel.showCancelAlert = { [weak self] in + guard let self else { return } + viewModel.alertCoordinator?.presentAlert(environment: .recordingCancel, delegate: self) + } + + viewModel.showCompleteAlert = { [weak self] in + guard let self else { return } + viewModel.alertCoordinator?.presentAlert(environment: .recordingComplete, delegate: self) + } + + cancelButton.addAction(UIAction { [weak self] _ in + self?.viewModel.send(.openCancelAlertButtonTapped) + }, for: .touchUpInside) + + completeButton.addAction(UIAction { [weak self] _ in + self?.viewModel.send(.openCompleteAlertButtonTapped) + }, for: .touchUpInside) + + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: cancelButton) + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: completeButton) + + for item in [navigationItem.leftBarButtonItem, navigationItem.rightBarButtonItem] { + item?.hidesSharedBackground = true + } + } + + private func setupUI() { + for item in [titleLabel, durationLabel, recordButton, timestampLabel] { + item.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(item) + } + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 180), + titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + timestampLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 18), + timestampLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + timestampLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 160), + timestampLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), + timestampLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + durationLabel.topAnchor.constraint(equalTo: timestampLabel.bottomAnchor, constant: 120), + durationLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + durationLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), + durationLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + recordButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + recordButton.widthAnchor.constraint(equalToConstant: 120), + recordButton.heightAnchor.constraint(equalToConstant: 60), + recordButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -40), + recordButton.topAnchor.constraint(greaterThanOrEqualTo: durationLabel.bottomAnchor, constant: 48) + ]) + } + + private var recordButtonSymbolName: String { + switch viewModel.state.recordingState { + case .idle: + return "mic.fill" + case .recording: + return "pause.fill" + case .paused: + return "play.fill" + } + } +} + +// MARK: - Delegate + +extension RecordingViewController: ChaGokAlertButtonTappedDelegate { + public func recordingCancelCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) + } + + public func recordingCancelPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) { [weak self] in + self?.viewModel.send(.cancelButtonTapped) + } + } + + public func recordingCompleteCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) + } + + public func recordingCompletePrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) { [weak self] in + self?.viewModel.send(.finishButtonTapped) + } + } +} diff --git a/Presentation/Sources/View/Search/Cell/SearchHeader.swift b/Presentation/Sources/View/Search/Cell/SearchHeader.swift new file mode 100644 index 00000000..aea76572 --- /dev/null +++ b/Presentation/Sources/View/Search/Cell/SearchHeader.swift @@ -0,0 +1,80 @@ +import UIKit + +final class SearchHeader: UICollectionReusableView { + static let elementKind: String = "SearchHeader" + + // MARK: - Component + + private let container: UIStackView = { + let search = UIStackView() + search.translatesAutoresizingMaskIntoConstraints = false + search.axis = .horizontal + search.spacing = 2 + + return search + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .point700 + + return label + }() + + private let searchResultLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.setTypography(text: "검색 결과", style: .title3) + label.textColor = .gray800 + + return label + }() + + private let resultCountLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .point700 + + return label + }() + + // MARK: - Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setup() { + let spacer = UIView() + container.addArrangedSubview(titleLabel) + container.addArrangedSubview(searchResultLabel) + container.addArrangedSubview(resultCountLabel) + container.addArrangedSubview(spacer) + addSubview(container) + + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor, constant: 24), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + container.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Configure + + func configure( + title: String, + resultCount: Int + ) { + titleLabel.setTypography(text: title, style: .title3) + resultCountLabel.setTypography(text: String(resultCount), style: .title3) + } +} diff --git a/Presentation/Sources/View/Search/SearchViewController.swift b/Presentation/Sources/View/Search/SearchViewController.swift new file mode 100644 index 00000000..f43375aa --- /dev/null +++ b/Presentation/Sources/View/Search/SearchViewController.swift @@ -0,0 +1,313 @@ +import Domain +import SwiftUI +import UIKit + +public final class SearchViewController: ViewController { + // MARK: - Type + + enum Section: Hashable { + case empty + case emptyResult + case result + } + + enum Item: Hashable { + case empty + case emptyResult + case result(ContentItem) + } + + typealias DataSource = UICollectionViewDiffableDataSource + typealias SnapShot = NSDiffableDataSourceSnapshot + typealias CellRegistration = UICollectionView.CellRegistration + typealias HeaderRegistration = UICollectionView.SupplementaryRegistration + + // MARK: - Component + + private let searchBar: ChagokSearchBar = .init() + private lazy var collectionView: CollectionView = { + let layout = UICollectionViewFlowLayout() + let view = CollectionView(frame: .zero, collectionViewLayout: layout) + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + return view + }() + + private var dataSource: DataSource! + private let vm: SearchViewModel + + // MARK: - Initialize + + public init(vm: SearchViewModel) { + self.vm = vm + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + setup() + setupSearchBar() + setupCollectionView() + } + + override public func updateProperties() { + super.updateProperties() + updateNavigationBarAppearance(isTransparent: false) + updateDataSource() + updateVisibleHeader() + } + + // MARK: - SetUp + + private func setup() { + navigationItem.titleView = searchBar + navigationItem.hidesBackButton = true + } + + private func setupSearchBar() { + searchBar.textField.delegate = self + + searchBar.closeButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + searchBar.textField.text = nil + vm.clearSearch() + }, for: .touchUpInside) + } + + private func setupCollectionView() { + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + collectionView.setCollectionViewLayout(createLayout(), animated: false) + + // 셀 등록 + let emptyCellRegistration = CellRegistration { cell, _, _ in + cell.backgroundConfiguration = .clear() + cell.contentConfiguration = nil + } + + let emptyResultCellRegistration = CellRegistration { cell, _, _ in + cell.backgroundConfiguration = .clear() + cell.contentConfiguration = EmptyContentConfiguration( + message: "검색 결과가 없습니다.\n다른 검색어로 검색해보세요." + ) + } + + let resultCellRegistration = CellRegistration { cell, _, item in + guard case .result(let item) = item else { return } + cell.backgroundConfiguration = .clear() + cell.contentConfiguration = UIHostingConfiguration { + switch item { + case .folder(let folder): + SearchFolderCardView( + fullText: folder.name, + keyword: self.vm.query, + createdAt: folder.createdAt.searchFolderText(), + voiceNoteCount: folder.voiceNoteIDs.count + ) { [weak self] in + self?.vm.pushFolder(folder) + } + case .voiceNote(let voiceNote): + SearchVoiceNoteCardView( + title: voiceNote.title, + keyword: self.vm.query, + timeline: Date.now.searchVoiceNoteDay( + createdAt: voiceNote.createdAt, + updatedAt: voiceNote.updatedAt, + duration: voiceNote.voiceRecord.duration, + folderName: self.vm.parentFolder(id: voiceNote.folderID) + ) + ) { [weak self] in + self?.vm.pushVoiceNote(voiceNote) + } + } + } + .margins(.all, 0) + } + + // DataSource 설정 + dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .empty: + return collectionView.dequeueConfiguredReusableCell( + using: emptyCellRegistration, for: indexPath, item: item + ) + case .emptyResult: + return collectionView.dequeueConfiguredReusableCell( + using: emptyResultCellRegistration, for: indexPath, item: item + ) + case .result: + return collectionView.dequeueConfiguredReusableCell( + using: resultCellRegistration, for: indexPath, item: item + ) + } + } + + // 전역 Header + let headerRegistration = HeaderRegistration(elementKind: SearchHeader.elementKind) { [weak self] header, _, _ in + guard let self else { return } + header.configure( + title: vm.type.title, + resultCount: vm.filteredItems.count + ) + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + if kind == SearchHeader.elementKind { + return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath) + } + return nil + } + + updateDataSource() + } +} + +// MARK: - Update Method + +extension SearchViewController { + private func updateDataSource() { + var snapshot = SnapShot() + + switch vm.searchState { + case .empty: + snapshot.appendSections([.empty]) + snapshot.appendItems([.empty], toSection: .empty) + + case .emptyResult: + snapshot.appendSections([.emptyResult]) + snapshot.appendItems([.emptyResult], toSection: .emptyResult) + + case .result: + snapshot.appendSections([.result]) + let resultItems = vm.filteredItems.map(Item.result) + snapshot.appendItems(resultItems, toSection: .result) + snapshot.reconfigureItems(resultItems) + } + + dataSource.apply(snapshot, animatingDifferences: true) + } + + private func updateVisibleHeader() { + guard let header = collectionView + .visibleSupplementaryViews(ofKind: SearchHeader.elementKind) + .first as? SearchHeader + else { return } + + header.configure( + title: vm.type.title, + resultCount: vm.filteredItems.count + ) + } +} + +// MARK: - Layout + +extension SearchViewController { + private func createLayout() -> UICollectionViewCompositionalLayout { + let sectionProvider: UICollectionViewCompositionalLayoutSectionProvider = { [weak self] sectionIndex, _ in + guard let self, + let section = dataSource.sectionIdentifier(for: sectionIndex) + else { + return self?.emptySection() + } + + switch section { + case .empty, .emptyResult: + return createSection( + itemWidth: .fractionalWidth(1.0), + itemHeight: .estimated(300), + groupWidth: .fractionalWidth(1.0), + groupHeight: .estimated(300) + ) + case .result: + return createSection( + itemWidth: .fractionalWidth(1.0), + itemHeight: .estimated(120), + groupWidth: .fractionalWidth(1.0), + groupHeight: .estimated(120), + interGroupSpacing: 8, + contentInsets: .init(top: 16, leading: 20, bottom: 0, trailing: 20), + boundarySupplementaryItems: [searchHeaderItem()] + ) + } + } + + return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider) + } + + private func createSection( + itemWidth: NSCollectionLayoutDimension, + itemHeight: NSCollectionLayoutDimension, + groupWidth: NSCollectionLayoutDimension, + groupHeight: NSCollectionLayoutDimension, + interGroupSpacing: CGFloat = 0.0, + contentInsets: NSDirectionalEdgeInsets = .zero, + boundarySupplementaryItems: [NSCollectionLayoutBoundarySupplementaryItem] = [] + ) -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize(widthDimension: itemWidth, heightDimension: itemHeight) + let groupSize = NSCollectionLayoutSize(widthDimension: groupWidth, heightDimension: groupHeight) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = interGroupSpacing + section.contentInsets = contentInsets + section.boundarySupplementaryItems = boundarySupplementaryItems + return section + } + + private func searchHeaderItem() -> NSCollectionLayoutBoundarySupplementaryItem { + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(44) + ), + elementKind: SearchHeader.elementKind, + alignment: .top + ) + header.pinToVisibleBounds = true + header.zIndex = 1000 + return header + } + + private func emptySection() -> NSCollectionLayoutSection { + createSection( + itemWidth: .fractionalWidth(0), + itemHeight: .fractionalHeight(0), + groupWidth: .fractionalWidth(0), + groupHeight: .fractionalHeight(0) + ) + } +} + +// MARK: 검색 Delegate + +extension SearchViewController: UITextFieldDelegate { + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + vm.search(searchBar.textField.text ?? "") + return true + } +} + +#if DEBUG + #Preview { + UINavigationController( + rootViewController: SearchViewController( + vm: .preview() + ) + ) + } +#endif diff --git a/Presentation/Sources/View/Setting/Cell/SettingLanguageContentConfiguration.swift b/Presentation/Sources/View/Setting/Cell/SettingLanguageContentConfiguration.swift new file mode 100644 index 00000000..0c562a08 --- /dev/null +++ b/Presentation/Sources/View/Setting/Cell/SettingLanguageContentConfiguration.swift @@ -0,0 +1,186 @@ +import Domain +import UIKit + +struct SettingLanguageContentConfiguration: UIContentConfiguration { + let title: String + let subtitle: String? + let language: Language + let action: (Language) -> Void + + func makeContentView() -> any UIView & UIContentView { + SettingLanguageContent(configuration: self) + } + + func updated(for state: any UIConfigurationState) -> Self { + self + } +} + +final class SettingLanguageContent: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + // MARK: - State + + private var settingConfig: SettingLanguageContentConfiguration? { + configuration as? SettingLanguageContentConfiguration + } + + private var languageCheckmarks: [Language: UIImageView] = [:] + private var contentHeight: CGFloat = 0 + + // MARK: - Component + + private lazy var titleLabel: UILabel = { + let title = UILabel() + title.translatesAutoresizingMaskIntoConstraints = false + title.setTypography(text: settingConfig?.title, style: .title3) + title.textColor = UIColor.gray950 + return title + }() + + private lazy var subTitleLabel: UILabel = { + let subTitle = UILabel() + subTitle.translatesAutoresizingMaskIntoConstraints = false + subTitle.setTypography(text: settingConfig?.subtitle, style: .caption) + subTitle.textColor = UIColor.gray700 + return subTitle + }() + + private lazy var mainStackView: UIStackView = { + let stack = UIStackView() + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 16 + return stack + }() + + // MARK: - Initialize + + init(configuration: any UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setup() + apply(configuration: configuration) + } + + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard bounds.width > 0 else { return } + + let measuredSize = systemLayoutSizeFitting( + CGSize(width: bounds.width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + + if abs(contentHeight - measuredSize.height) > 0.5 { + contentHeight = measuredSize.height + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + guard contentHeight > 0 else { + return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) + } + + return CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) + } + + // MARK: - Setup + + private func setup() { + addSubview(titleLabel) + addSubview(subTitleLabel) + addSubview(mainStackView) + + NSLayoutConstraint.activate([ + // Title + titleLabel.topAnchor.constraint(equalTo: topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + // subTitle + subTitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + subTitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + subTitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + // Container + mainStackView.topAnchor.constraint(equalTo: subTitleLabel.bottomAnchor, constant: 24), + mainStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + mainStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20) + ]) + + let bottomConstraint = mainStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16) + bottomConstraint.priority = UILayoutPriority(999) + bottomConstraint.isActive = true + + for lang in Language.allCases { + let (rowStack, checkmark) = makeLanguageRow(language: lang) + + rowStack.addTapGesture { [weak self] in + guard let self else { return } + settingConfig?.action(lang) + } + + mainStackView.addArrangedSubview(rowStack) + languageCheckmarks[lang] = checkmark + } + } + + private func makeLanguageRow(language: Language) -> (stackView: UIStackView, checkmarkView: UIImageView) { + let checkmarkView = UIImageView() + + let titleLabel = UILabel() + let name: String = switch language { + case .ko: "한국어" + case .en: "영어" + } + titleLabel.setTypography(text: name, style: .title2) + titleLabel.textColor = UIColor.gray950 + + // spacer + let spacer = UIView() + + let rowStack = UIStackView(arrangedSubviews: [checkmarkView, titleLabel, spacer]) + rowStack.axis = .horizontal + rowStack.spacing = 16 + rowStack.alignment = .center + rowStack.layoutMargins = .init(top: 16, left: 16, bottom: 16, right: 16) + rowStack.isLayoutMarginsRelativeArrangement = true + + let tintColor: UIColor = .point200.withAlphaComponent(0.2) + rowStack.applyGlassEffect(isInteractive: true, tintColor: tintColor) + + return (rowStack, checkmarkView) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? SettingLanguageContentConfiguration else { return } + + let baseConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) + for (lang, checkmark) in languageCheckmarks { + let isSelected = (lang == config.language) + + if isSelected { + // 선택됨 + let paletteConfig = baseConfig.applying(UIImage.SymbolConfiguration(paletteColors: [.white, .point800])) + checkmark.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: paletteConfig) + } else { + // 선택 안 됨 + let normalConfig = baseConfig.applying(UIImage.SymbolConfiguration(paletteColors: [.gray600])) + checkmark.image = UIImage(systemName: "circle.fill", withConfiguration: normalConfig) + } + } + + setNeedsLayout() + invalidateIntrinsicContentSize() + } +} diff --git a/Presentation/Sources/View/Setting/Cell/SettingModelContentConfiguration.swift b/Presentation/Sources/View/Setting/Cell/SettingModelContentConfiguration.swift new file mode 100644 index 00000000..69095331 --- /dev/null +++ b/Presentation/Sources/View/Setting/Cell/SettingModelContentConfiguration.swift @@ -0,0 +1,273 @@ +import Domain +import UIKit + +// MARK: - SettingModelContentConfiguration + +struct SettingModelContentConfiguration: UIContentConfiguration { + enum ActionType { + case download + case delete + } + + let title: String + let models: [ChaGokModelState] + var action: ((ChaGokModel, ActionType) -> Void)? + + func makeContentView() -> any UIView & UIContentView { + SettingModelContent(configuration: self) + } + + func updated(for state: any UIConfigurationState) -> Self { + self + } +} + +// MARK: - SettingModelContent + +final class SettingModelContent: UIView, UIContentView { + var configuration: any UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + // MARK: - Components + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .gray950 + if let config = configuration as? SettingModelContentConfiguration { + label.setTypography(text: config.title, style: .title2) + } + return label + }() + + private let cardsStackView: UIStackView = { + let stack = UIStackView() + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 12 + return stack + }() + + /// 빈 배열일 때 UIStackView 높이 무한대 크래쉬 방지용 더미 뷰 + private let dummySpacer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.heightAnchor.constraint(equalToConstant: 0).isActive = true + return view + }() + + // MARK: - Initialize + + init(configuration: any UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setup() + apply(configuration: configuration) + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setup() { + addSubview(titleLabel) + addSubview(cardsStackView) + cardsStackView.addArrangedSubview(dummySpacer) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + + cardsStackView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + cardsStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + cardsStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20) + ]) + + let bottomConstraint = cardsStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16) + bottomConstraint.priority = UILayoutPriority(999) + bottomConstraint.isActive = true + + // 서브뷰 생성은 apply()에서 처리합니다. + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? SettingModelContentConfiguration else { return } + + let activeCardViews = cardsStackView.arrangedSubviews.compactMap { $0 as? SettingModelCardView } + + if activeCardViews.count == config.models.count { + for (index, modelState) in config.models.enumerated() { + activeCardViews[index].update(modelState: modelState) { [weak self] targetModel, actionType in + guard let self else { return } + if let currentConfig = self.configuration as? SettingModelContentConfiguration { + currentConfig.action?(targetModel, actionType) + } + } + } + } else { + for arrangedSubview in cardsStackView.arrangedSubviews { + if arrangedSubview !== dummySpacer { + arrangedSubview.removeFromSuperview() + } + } + + for modelState in config.models { + let card = SettingModelCardView() + card.update(modelState: modelState) { [weak self] targetModel, actionType in + guard let self else { return } + if let currentConfig = self.configuration as? SettingModelContentConfiguration { + currentConfig.action?(targetModel, actionType) + } + } + cardsStackView.addArrangedSubview(card) + } + } + } +} + +// MARK: - SettingModelCardView + +final class SettingModelCardView: UIStackView { + private let iconImageView: UIImageView = { + let iv = UIImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + let iconConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + iv.image = UIImage(systemName: "externaldrive", withConfiguration: iconConfig) + iv.tintColor = .gray950 + iv.contentMode = .scaleAspectFit + return iv + }() + + private let titleLabel = UILabel() + private let descLabel: UILabel = { + let label = UILabel() + label.textColor = .gray750 + label.numberOfLines = 0 + return label + }() + + private let actionButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private let activityIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.hidesWhenStopped = true + return indicator + }() + + private var currentModel: ChaGokModel = .none + private var currentActionType: SettingModelContentConfiguration.ActionType = .download + private var onAction: ((ChaGokModel, SettingModelContentConfiguration.ActionType) -> Void)? + + init() { + super.init(frame: .zero) + setup() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + axis = .horizontal + spacing = 16 + alignment = .center + layoutMargins = .init(top: 16, left: 16, bottom: 16, right: 16) + isLayoutMarginsRelativeArrangement = true + + let innerVerticalStack = UIStackView() + innerVerticalStack.axis = .vertical + innerVerticalStack.spacing = 8 + innerVerticalStack.alignment = .leading + + let titleHorizontalStack = UIStackView() + titleHorizontalStack.axis = .horizontal + titleHorizontalStack.spacing = 8 + titleHorizontalStack.alignment = .center + + titleLabel.textColor = .gray950 + + titleHorizontalStack.addArrangedSubview(iconImageView) + titleHorizontalStack.addArrangedSubview(titleLabel) + + innerVerticalStack.addArrangedSubview(titleHorizontalStack) + innerVerticalStack.addArrangedSubview(descLabel) + + let rightControlContainer = UIView() + rightControlContainer.translatesAutoresizingMaskIntoConstraints = false + rightControlContainer.addSubview(actionButton) + rightControlContainer.addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + actionButton.centerXAnchor.constraint(equalTo: rightControlContainer.centerXAnchor), + actionButton.centerYAnchor.constraint(equalTo: rightControlContainer.centerYAnchor), + actionButton.widthAnchor.constraint(equalToConstant: 24), + actionButton.heightAnchor.constraint(equalToConstant: 24), + + activityIndicator.centerXAnchor.constraint(equalTo: rightControlContainer.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: rightControlContainer.centerYAnchor), + + rightControlContainer.widthAnchor.constraint(equalToConstant: 24), + rightControlContainer.heightAnchor.constraint(equalToConstant: 24), + + iconImageView.widthAnchor.constraint(equalToConstant: 20), + iconImageView.heightAnchor.constraint(equalToConstant: 20) + ]) + + addArrangedSubview(innerVerticalStack) + addArrangedSubview(rightControlContainer) + + actionButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + onAction?(currentModel, currentActionType) + }, for: .touchUpInside) + + let tintColor: UIColor = .point200.withAlphaComponent(0.2) + applyGlassEffect(tintColor: tintColor) + } + + func update( + modelState: ChaGokModelState, + onAction: @escaping (ChaGokModel, SettingModelContentConfiguration.ActionType) -> Void + ) { + currentModel = modelState.model + self.onAction = onAction + + titleLabel.setTypography(text: modelState.title, style: .subtitle2) + descLabel.setTypography(text: modelState.subTitle, style: .caption) + + let actionConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .regular) + switch modelState.status.storage { + case .downloaded: + actionButton.isHidden = false + activityIndicator.stopAnimating() + actionButton.setImage(UIImage(systemName: "trash", withConfiguration: actionConfig), for: .normal) + actionButton.tintColor = .danger + currentActionType = .delete + case .downloading: + actionButton.isHidden = true + activityIndicator.startAnimating() + currentActionType = .download + default: + actionButton.isHidden = false + activityIndicator.stopAnimating() + actionButton.setImage( + UIImage(systemName: "square.and.arrow.down", withConfiguration: actionConfig), + for: .normal + ) + actionButton.tintColor = .point800 + currentActionType = .download + } + } +} diff --git a/Presentation/Sources/View/Setting/PrivacyPolicyViewController.swift b/Presentation/Sources/View/Setting/PrivacyPolicyViewController.swift new file mode 100644 index 00000000..f6421e8a --- /dev/null +++ b/Presentation/Sources/View/Setting/PrivacyPolicyViewController.swift @@ -0,0 +1,58 @@ +import UIKit +import WebKit + +/// 개인정보 처리 방침 WebView Controller +public final class PrivacyPolicyViewController: UIViewController, WKUIDelegate { + // MARK: - Component + + private let backItem: NavigationItemButton = .init( + normalItem: .init(title: "개인정보 처리 방침", imageName: "chevron.left"), + selectedItem: .init(title: "개인정보 처리 방침", imageName: "chevron.left"), + attributedString: Typography.header2.textAttributes + ) + + private var webView: WKWebView! + + override public func loadView() { + super.loadView() + view = UIView() + let configuration = WKWebViewConfiguration() + webView = WKWebView(frame: .zero, configuration: configuration) + webView.uiDelegate = self + webView.translatesAutoresizingMaskIntoConstraints = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + override public func viewDidLoad() { + super.viewDidLoad() + setupNavigation() + setupWebView() + } + + private func setupNavigation() { + backItem.addAction(UIAction { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + }, for: .touchUpInside) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backItem) + navigationItem.leftBarButtonItem?.hidesSharedBackground = true + } + + private func setupWebView() { + guard let privacyUrl = URL(string: Constant.privacyPolicy) else { return } + let request = URLRequest(url: privacyUrl) + webView.load(request) + } +} + +#Preview { + UINavigationController( + rootViewController: PrivacyPolicyViewController() + ) +} diff --git a/Presentation/Sources/View/Setting/SettingViewController.swift b/Presentation/Sources/View/Setting/SettingViewController.swift new file mode 100644 index 00000000..397a676f --- /dev/null +++ b/Presentation/Sources/View/Setting/SettingViewController.swift @@ -0,0 +1,190 @@ +import Domain +import UIKit + +@MainActor +public final class SettingViewController: CollectionViewController { + // MARK: - Type + + typealias Section = SettingViewModel.Section + typealias Item = SettingViewModel.Item + typealias DataSource = UICollectionViewDiffableDataSource + typealias SnapShot = NSDiffableDataSourceSnapshot + + // MARK: - Component + + let backItem: NavigationItemButton = .init( + normalItem: .init(title: "설정", imageName: "chevron.left"), + selectedItem: .init(title: "설정", imageName: "chevron.left"), + attributedString: Typography.header2.textAttributes + ) + + private lazy var dataSource: DataSource = makeDataSource() + private let vm: SettingViewModel + + // MARK: - Initialize + + public init(vm: SettingViewModel) { + var listConfiguration = UICollectionLayoutListConfiguration(appearance: .plain) + listConfiguration.backgroundColor = .clear + listConfiguration.showsSeparators = false + + let layout = UICollectionViewCompositionalLayout.list(using: listConfiguration) + self.vm = vm + super.init(collectionViewLayout: layout) + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + setupNavigation() + applySnapShot(animate: false) + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + vm.checkModels() + } + + override public func updateProperties() { + super.updateProperties() + applySnapShot(animate: false) + } + + // MARK: - Setup + + private func setupNavigation() { + updateNavigationBarAppearance(isTransparent: false) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backItem) + navigationItem.leftBarButtonItem?.hidesSharedBackground = true + backItem.addAction(UIAction { [weak self] _ in + self?.vm.pop() + }, for: .touchUpInside) + } + + // MARK: - DataSource + + private func makeDataSource() -> DataSource { + let langCellRegistration = UICollectionView + .CellRegistration { [weak self] cell, indexPath, itemIdentifier in + guard case .lang(let language) = itemIdentifier.data else { return } + var backgroundConfig = UIBackgroundConfiguration.listCell() + backgroundConfig.backgroundColor = .clear + cell.backgroundConfiguration = backgroundConfig + cell.contentConfiguration = SettingLanguageContentConfiguration( + title: itemIdentifier.title, + subtitle: itemIdentifier.subTitle, + language: language, + action: { selectedLanguage in + self?.vm.setLanguage(selectedLanguage) + } + ) + } + + let modelCellRegistration = UICollectionView + .CellRegistration { [weak self] cell, indexPath, itemIdentifier in + guard case .model(let models) = itemIdentifier.data else { return } + var backgroundConfig = UIBackgroundConfiguration.listCell() + backgroundConfig.backgroundColor = .clear + cell.backgroundConfiguration = backgroundConfig + cell.contentConfiguration = SettingModelContentConfiguration( + title: itemIdentifier.title, + models: models, + action: { [weak self] targetModel, actionType in + switch actionType { + case .download: + self?.vm.downloadModel(model: targetModel) + case .delete: + self?.vm.deleteModel(model: targetModel) + } + } + ) + } + + let defaultCellRegistration = UICollectionView + .CellRegistration { cell, indexPath, itemIdentifier in + guard case .none(let label) = itemIdentifier.data else { return } + var content = cell.defaultContentConfiguration() + content.text = itemIdentifier.title + content.secondaryText = itemIdentifier.subTitle + content.textProperties.font = Typography.title3.font + content.textProperties.color = .gray950 + cell.contentConfiguration = content + cell.backgroundConfiguration = .clear() + cell.addTapGesture { [weak self] in + switch label { + case .privacyPolicy: self?.vm.pushPrivacyPolicy() + case .termsOfUse: self?.vm.pushTermsOfUse() + case .customerInquiry: self?.customerInquiryLink() + } + } + } + + return DataSource(collectionView: collectionView) { col, indexPath, itemIdentifier in + switch itemIdentifier.data { + case .lang: + return col.dequeueConfiguredReusableCell( + using: langCellRegistration, + for: indexPath, + item: itemIdentifier + ) + case .model: + return col.dequeueConfiguredReusableCell( + using: modelCellRegistration, + for: indexPath, + item: itemIdentifier + ) + case .none: + return col.dequeueConfiguredReusableCell( + using: defaultCellRegistration, + for: indexPath, + item: itemIdentifier + ) + } + } + } + + private func applySnapShot(animate: Bool) { + var snapshot = SnapShot() + snapshot.appendSections([.lang, .model, .label]) + let langData: [Item] = [ + Item(title: "언어 선택", subTitle: "녹음 기록 언어를 바꿉니다", data: .lang(vm.language)) + ] + snapshot.appendItems(langData, toSection: .lang) + + let modelItems = [ + Item(title: "음성 인식 모델 설정", subTitle: "기본 모델", data: .model(vm.models)) + ] + snapshot.appendItems(modelItems, toSection: .model) + + let labelItems = [ + Item(title: "이용약관", subTitle: nil, data: .none(.termsOfUse)), + Item(title: "개인정보 처리 방침", subTitle: nil, data: .none(.privacyPolicy)), + Item(title: "고객 문의", subTitle: nil, data: .none(.customerInquiry)) + ] + snapshot.appendItems(labelItems, toSection: .label) + + dataSource.apply(snapshot, animatingDifferences: animate) + } + + /// 고객 문의 링크 함수 + private func customerInquiryLink() { + if let url = URL(string: Constant.customerInquiry) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } +} + +#if DEBUG + #Preview { + UINavigationController( + rootViewController: SettingViewController( + vm: .preview + ) + ) + } +#endif diff --git a/Presentation/Sources/View/Setting/TermsOfUseViewController.swift b/Presentation/Sources/View/Setting/TermsOfUseViewController.swift new file mode 100644 index 00000000..3d19b2bf --- /dev/null +++ b/Presentation/Sources/View/Setting/TermsOfUseViewController.swift @@ -0,0 +1,52 @@ +import UIKit +import WebKit + +/// 이용약관 WebView Controller +public final class TermsOfUseViewController: UIViewController, WKUIDelegate { + // MARK: - Component + + let backItem: NavigationItemButton = .init( + normalItem: .init(title: "이용 약관", imageName: "chevron.left"), + selectedItem: .init(title: "이용 약관", imageName: "chevron.left"), + attributedString: Typography.header2.textAttributes + ) + + private var webView: WKWebView! + + override public func loadView() { + super.loadView() + view = UIView() + let configuration = WKWebViewConfiguration() + webView = WKWebView(frame: .zero, configuration: configuration) + webView.uiDelegate = self + webView.translatesAutoresizingMaskIntoConstraints = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + override public func viewDidLoad() { + super.viewDidLoad() + setupNavigation() + setupWebView() + } + + private func setupNavigation() { + backItem.addAction(UIAction { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + }, for: .touchUpInside) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backItem) + navigationItem.leftBarButtonItem?.hidesSharedBackground = true + } + + private func setupWebView() { + guard let privacyUrl = URL(string: Constant.termsOfUse) else { return } + let request = URLRequest(url: privacyUrl) + webView.load(request) + } +} diff --git a/Presentation/Sources/View/Trash/Cell/TrashHeaderCell.swift b/Presentation/Sources/View/Trash/Cell/TrashHeaderCell.swift new file mode 100644 index 00000000..53d03c01 --- /dev/null +++ b/Presentation/Sources/View/Trash/Cell/TrashHeaderCell.swift @@ -0,0 +1,35 @@ +import UIKit + +final class TrashHeaderCell: UICollectionReusableView { + private let titleLabel: UILabel = { + let label = UILabel() + label.textColor = .gray700 + label.setTypography( + text: "휴지통에 옮긴 기록은 직접 비우기 전까지 보관되며,\n언제든 복원할 수 있습니다.", + style: .body1 + ) + label.numberOfLines = 2 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + private func setupUI() { + addSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16) + ]) + } +} diff --git a/Presentation/Sources/View/Trash/TrashViewController.swift b/Presentation/Sources/View/Trash/TrashViewController.swift new file mode 100644 index 00000000..f7995b26 --- /dev/null +++ b/Presentation/Sources/View/Trash/TrashViewController.swift @@ -0,0 +1,345 @@ +import Domain +import SwiftUI +import UIKit + +public final class TrashViewController: CollectionViewController { + enum Section { + case main + } + + typealias DataSource = UICollectionViewDiffableDataSource + typealias SnapShot = NSDiffableDataSourceSnapshot + + private var dataSource: DataSource? + + // MARK: - Component + + private lazy var backButton: NavigationItemButton = .init( + normalItem: .init(title: "휴지통", imageName: "chevron.left"), + selectedItem: .init(title: "", imageName: "xmark"), + attributedString: Typography.title1.textAttributes + ) + + private lazy var moreAndActionButton: NavigationItemButton = .init( + normalItem: .init(imageName: "ellipsis"), + selectedItem: .init(title: "삭제"), + attributedString: Typography.title1.textAttributes, + selectedForegroundColor: .danger + ) + + private lazy var searchAndMoveButton: NavigationItemButton = .init( + normalItem: .init(imageName: "magnifyingglass"), + selectedItem: .init(title: "복원"), + attributedString: Typography.title1.textAttributes + ) + + private lazy var emptyTrashAction = UIAction( + title: "휴지통 비우기", + image: nil, + attributes: .destructive // 강조(빨간색) 효과 + ) { [weak self] _ in + guard let self else { return } + vm.alertCoordinator?.presentAlert( + environment: .deleteAllTrash, delegate: self + ) + } + + private lazy var selectAction = UIAction( + title: "선택하기", + image: nil + ) { [weak self] _ in + self?.vm.setSelectionMode(.multiple) + } + + private lazy var selectAllAction = UIAction( + title: "전체 선택하기", + image: nil + ) { [weak self] _ in + self?.vm.setSelectionMode(.all) + } + + private let vm: TrashViewModel + + public init(vm: TrashViewModel) { + self.vm = vm + let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in + var listConfiguration = UICollectionLayoutListConfiguration(appearance: .plain) + listConfiguration.headerMode = .supplementary + listConfiguration.showsSeparators = false + listConfiguration.backgroundColor = .clear + + let section = NSCollectionLayoutSection.list(using: listConfiguration, layoutEnvironment: layoutEnvironment) + section.contentInsets = .init(top: 12, leading: 20, bottom: 20, trailing: 20) + section.interGroupSpacing = 8 + section.boundarySupplementaryItems.forEach { $0.pinToVisibleBounds = false } + return section + } + super.init(collectionViewLayout: layout) + } + + required init?(coder: NSCoder) { + nil + } + + // MARK: - LifeCycle + + override public func viewDidLoad() { + super.viewDidLoad() + collectionView.allowsSelection = false + collectionView.showsVerticalScrollIndicator = false + updateNavigationBarAppearance(isTransparent: false) + setupNavigation() + setupDataSource() + updateDataSource() + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + vm.onAppear() + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + vm.onDisappear() + } + + override public func updateProperties() { + super.updateProperties() + // navigation item + updateNavigationItems(vm.select) + updateRightBarButtonMenu(vm.select) + // dataSource + updateDataSource(reconfigure: true) + } + + private func setupNavigation() { + let leftItem = UIBarButtonItem(customView: backButton) + navigationItem.leftBarButtonItem = leftItem + + backButton.addAction(backButtonAction(), for: .touchUpInside) + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(customView: moreAndActionButton), + UIBarButtonItem(customView: searchAndMoveButton) + ] + moreAndActionButton.addAction(moreAndActionButtonAction(), for: .touchUpInside) + searchAndMoveButton.addAction(searchAndMoveButtonAction(), for: .touchUpInside) + + updateRightBarButtonMenu(vm.select) + navigationItem.leftBarButtonItem?.hidesSharedBackground = true + navigationItem.rightBarButtonItems?.forEach { + $0.hidesSharedBackground = true + } + } + + private func setupDataSource() { + let headerRegistration = UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { _, _, _ in } + + let cellRegistration = UICollectionView.CellRegistration { [weak self] ( + cell: UICollectionViewListCell, + indexPath: IndexPath, + itemIdentifier: ContentItem + ) in + guard let self else { return } + var backgroundConfig = UIBackgroundConfiguration.listCell() + backgroundConfig.backgroundColor = .clear + cell.backgroundConfiguration = backgroundConfig + + switch itemIdentifier { + case .folder(let folder): + cell.contentConfiguration = UIHostingConfiguration { + TrashFolderCardView( + select: vm.select, + isSelected: vm.selectedItems.contains(.folder(folder)), + folder: folder + ) { [weak self] data, state in + if state { + self?.vm.selectItem(.folder(data)) + } else { + self?.vm.deselectItem(.folder(data)) + } + } completeAction: { [weak self] in + self?.vm.pushDetailFolder(folder) + } + } + .margins(.all, 0) + case .voiceNote(let voiceNote): + cell.contentConfiguration = UIHostingConfiguration { + TrashVoiceNoteCardView( + select: vm.select, + isSelected: vm.selectedItems.contains(.voiceNote(voiceNote)), + voiceNote: voiceNote + ) { [weak self] data, state in + if state { + self?.vm.selectItem(.voiceNote(data)) + } else { + self?.vm.deselectItem(.voiceNote(data)) + } + } completeAction: { [weak self] in + self?.vm.pushVoiceNote(voiceNote) + } + } + .margins(.all, 0) + } + } + + dataSource = DataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, itemIdentifier in + return collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: itemIdentifier + ) + } + ) + + dataSource?.supplementaryViewProvider = { collectionView, kind, indexPath in + return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath) + } + } +} + +// MARK: - Update Method + +extension TrashViewController { + private func updateRightBarButtonMenu(_ select: SelectionMode) { + let menu: UIMenu = .init( + title: "", + children: [selectAllAction, selectAction, emptyTrashAction] + ) + moreAndActionButton.menu = menu + } + + private func updateNavigationItems(_ select: SelectionMode) { + let isEditMode = (select != .none) + for item in [backButton, moreAndActionButton, searchAndMoveButton] { + item.isSelected = isEditMode + item.sizeToFit() + } + moreAndActionButton.showsMenuAsPrimaryAction = !isEditMode + } + + private func updateDataSource(reconfigure: Bool = false) { + var snapshot = SnapShot() + snapshot.appendSections([.main]) + snapshot.appendItems(vm.items) + if reconfigure { + snapshot.reconfigureItems(vm.items) + } + dataSource?.apply(snapshot, animatingDifferences: true) + } + + func updateInteractionForAlert(isPresented: Bool) { + collectionView.isUserInteractionEnabled = !isPresented + backButton.isUserInteractionEnabled = !isPresented + moreAndActionButton.isUserInteractionEnabled = !isPresented + searchAndMoveButton.isUserInteractionEnabled = !isPresented + } +} + +// MARK: - Helper Method + +private extension TrashViewController { + func selectedItemsForBulkAction() -> [ContentItem]? { + switch vm.select { + case .none: + return nil + case .multiple, .all: + guard !vm.selectedItems.isEmpty else { + vm.setSelectionMode(.none) + return nil + } + return vm.selectedItems + } + } + + func backButtonAction() -> UIAction { + UIAction { [weak self] _ in + guard let self else { return } + switch vm.select { + case .none: + vm.didTapBack() + case .all, .multiple: + vm.setSelectionMode(.none) + } + } + } + + func moreAndActionButtonAction() -> UIAction { + UIAction { [weak self] _ in + guard let self else { return } + vm.deleteButtonTapped { [weak self] in + guard let self else { return } + vm.alertCoordinator?.presentAlert( + environment: .deleteItemsTrash, + delegate: self + ) + } + } + } + + func searchAndMoveButtonAction() -> UIAction { + UIAction { [weak self] _ in + guard let self else { return } + switch vm.select { + case .none: + vm.pushSearch() + case .multiple, .all: + guard let selectedItems = selectedItemsForBulkAction() else { + return + } + vm.restore(items: selectedItems) + chagokBackgroundView.makeToast("원래 위치로 복원됐어요.") { [weak self] in + self?.vm.cancelRestore(items: selectedItems) + } + } + } + } +} + +// MARK: - Delegate + +extension TrashViewController: ChaGokAlertButtonTappedDelegate { + public func deleteAllTrashCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) + } + + public func deleteAllTrashPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) { [weak self] in + self?.vm.deleteAll() + self?.chagokBackgroundView.makeToast( + type: .normal, + "영구 삭제 되었습니다" + ) + } + } + + public func deleteItemsTrashCloseButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) + } + + public func deleteItemsTrashPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) { + alertVC.dismiss(animated: true) { [weak self] in + guard let selectedItems = self?.selectedItemsForBulkAction() else { + return + } + self?.vm.delete(items: selectedItems) + self?.chagokBackgroundView.makeToast( + type: .normal, + "영구 삭제 되었습니다" + ) + } + } +} + +#if DEBUG + #Preview { + UINavigationController( + rootViewController: TrashViewController( + vm: .preview() + ) + ) + } +#endif diff --git a/Presentation/Sources/View/VoiceNote/Cells/KeyPointCell.swift b/Presentation/Sources/View/VoiceNote/Cells/KeyPointCell.swift new file mode 100644 index 00000000..888423be --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/KeyPointCell.swift @@ -0,0 +1,146 @@ +import UIKit + +// MARK: - KeyPointContentConfiguration + +struct KeyPointContentConfiguration: UIContentConfiguration { + var number: Int = 0 + var text: String = "" + var highlightRanges: [NSRange] = [] + var focusedRange: NSRange? + + func makeContentView() -> UIView & UIContentView { + KeyPointContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> KeyPointContentConfiguration { + self + } +} + +// MARK: - KeyPointContentView + +final class KeyPointContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + // MARK: - UI Components + + private let badgeLabel: TypographyLabel = { + let label = TypographyLabel(typography: .title3, alignment: .center) + label.textColor = .white + label.backgroundColor = UIColor.point600 + label.layer.cornerRadius = Constant.keyPointBadgeSize / 2 + label.clipsToBounds = true + return label + }() + + private let textLabel: TypographyLabel = { + let label = TypographyLabel(typography: .body1) + label.textColor = UIColor.gray800 + label.numberOfLines = 0 + return label + }() + + private let contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = Constant.keyPointContentSpacing + stack.isLayoutMarginsRelativeArrangement = true + stack.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: Constant.keyPointCardVerticalPadding, + leading: Constant.keyPointCardHorizontalPadding, + bottom: Constant.keyPointCardVerticalPadding, + trailing: Constant.keyPointCardHorizontalPadding + ) + stack.backgroundColor = UIColor.gray100 + stack.layer.cornerRadius = Constant.cornerRadius + return stack + }() + + // MARK: - Init + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setupUI() + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setupUI() { + contentStack.addArrangedSubview(badgeLabel) + contentStack.addArrangedSubview(textLabel) + addSubview(contentStack) + + contentStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + badgeLabel.widthAnchor.constraint(equalToConstant: Constant.keyPointBadgeSize), + badgeLabel.heightAnchor.constraint(equalToConstant: Constant.keyPointBadgeSize), + + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor), + contentStack.topAnchor.constraint(equalTo: topAnchor), + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? KeyPointContentConfiguration else { return } + badgeLabel.text = "\(config.number)" + if config.highlightRanges.isEmpty { + textLabel.text = config.text + } else { + textLabel.attributedText = config.text.highlighted( + ranges: config.highlightRanges, + baseAttributes: Typography.body1.textAttributes, + highlightBackgroundColor: UIColor.point700, + focusedRange: config.focusedRange, + focusedHighlightBackgroundColor: .warning2 + ) + } + } +} + +// MARK: - Preview + +#Preview { + let firstConfig = KeyPointContentConfiguration( + number: 1, + text: "한 줄짜리 핵심 포인트 예시입니다." + ) + let secondConfig = KeyPointContentConfiguration( + number: 2, + text: "여러 줄로 길게 이어지는 핵심 포인트 예시입니다. 텍스트가 길어져도 뱃지는 수직 중앙에 정렬되어 유지됩니다." + ) + + let firstCell = KeyPointContentView(configuration: firstConfig) + let secondCell = KeyPointContentView(configuration: secondConfig) + + let stack = UIStackView(arrangedSubviews: [firstCell, secondCell]) + stack.axis = .vertical + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + + let container = UIView() + container.backgroundColor = .systemPink + container.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + + return container +} diff --git a/Presentation/Sources/View/VoiceNote/Cells/KeyPointSkeletonCell.swift b/Presentation/Sources/View/VoiceNote/Cells/KeyPointSkeletonCell.swift new file mode 100644 index 00000000..24bf43c2 --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/KeyPointSkeletonCell.swift @@ -0,0 +1,128 @@ +import UIKit + +// MARK: - KeyPointSkeletonContentConfiguration + +struct KeyPointSkeletonContentConfiguration: UIContentConfiguration { + var number: Int = 0 + var beginOffset: CFTimeInterval = 0 + + func makeContentView() -> UIView & UIContentView { + KeyPointSkeletonContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> KeyPointSkeletonContentConfiguration { + self + } +} + +// MARK: - KeyPointSkeletonContentView + +final class KeyPointSkeletonContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + // MARK: - UI Components + + private let badgeLabel: TypographyLabel = { + let label = TypographyLabel(typography: .title3, alignment: .center) + label.textColor = .white + label.backgroundColor = UIColor.point600 + label.layer.cornerRadius = Constant.keyPointBadgeSize / 2 + label.clipsToBounds = true + return label + }() + + private let skeletonLine = SkeletonLineView() + + private let contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = Constant.keyPointContentSpacing + stack.isLayoutMarginsRelativeArrangement = true + stack.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: Constant.keyPointCardVerticalPadding, + leading: Constant.keyPointCardHorizontalPadding, + bottom: Constant.keyPointCardVerticalPadding, + trailing: Constant.keyPointCardHorizontalPadding + ) + stack.backgroundColor = UIColor.gray100 + stack.layer.cornerRadius = Constant.cornerRadius + return stack + }() + + // MARK: - Init + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setupUI() + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setupUI() { + contentStack.addArrangedSubview(badgeLabel) + contentStack.addArrangedSubview(skeletonLine) + addSubview(contentStack) + + contentStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + badgeLabel.widthAnchor.constraint(equalToConstant: Constant.keyPointBadgeSize), + badgeLabel.heightAnchor.constraint(equalToConstant: Constant.keyPointBadgeSize), + + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor), + contentStack.topAnchor.constraint(equalTo: topAnchor), + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? KeyPointSkeletonContentConfiguration else { return } + badgeLabel.text = "\(config.number)" + skeletonLine.startAnimating(beginOffset: config.beginOffset) + } +} + +// MARK: - Preview + +#Preview { + let preview: UIView = { + let configs = [ + KeyPointSkeletonContentConfiguration(number: 1, beginOffset: 0.0), + KeyPointSkeletonContentConfiguration(number: 2, beginOffset: 0.2), + KeyPointSkeletonContentConfiguration(number: 3, beginOffset: 0.4) + ] + + let stack = UIStackView(arrangedSubviews: configs.map { $0.makeContentView() }) + stack.axis = .vertical + stack.spacing = 6 + stack.alignment = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + + let container = UIView() + container.backgroundColor = .systemPink + container.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + + return container + }() + + preview +} diff --git a/Presentation/Sources/View/VoiceNote/Cells/KeywordsCell.swift b/Presentation/Sources/View/VoiceNote/Cells/KeywordsCell.swift new file mode 100644 index 00000000..d53c095b --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/KeywordsCell.swift @@ -0,0 +1,110 @@ +import UIKit + +struct KeywordsContentConfiguration: UIContentConfiguration { + var keywords: [String] = [] + var keywordHighlightRanges: [[NSRange]] = [] + var focusedKeywordIndex: Int? + var focusedRange: NSRange? + + func makeContentView() -> UIView & UIContentView { + KeywordsContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> KeywordsContentConfiguration { + self + } +} + +final class KeywordsContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + private var chipLabels: [KeywordChipLabel] = [] + private var contentHeight: CGFloat = 0 + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + contentHeight = layoutChips(for: bounds.width) + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) + } + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? KeywordsContentConfiguration else { return } + + chipLabels.forEach { $0.removeFromSuperview() } + chipLabels = config.keywords.enumerated().map { index, keyword in + let chip = KeywordChipLabel(text: keyword) + let ranges = config.keywordHighlightRanges.indices.contains(index) + ? config.keywordHighlightRanges[index] : [] + let focusedRange = index == config.focusedKeywordIndex ? config.focusedRange : nil + chip.applyHighlight( + ranges: ranges, + highlightBackgroundColor: UIColor.point700, + focusedRange: focusedRange, + focusedHighlightBackgroundColor: .warning2 + ) + return chip + } + chipLabels.forEach(addSubview) + } + + private func layoutChips(for availableWidth: CGFloat) -> CGFloat { + guard availableWidth > 0, chipLabels.isEmpty == false else { return 0 } + + var xOffset: CGFloat = 0 + var yOffset: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for chipLabel in chipLabels { + let chipSize = chipLabel.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + if xOffset > 0, xOffset + chipSize.width > availableWidth { + xOffset = 0 + yOffset += rowHeight + Constant.keywordChipLineSpacing + rowHeight = 0 + } + + chipLabel.frame = CGRect(origin: CGPoint(x: xOffset, y: yOffset), size: chipSize) + + xOffset += chipSize.width + Constant.keywordChipInterItemSpacing + rowHeight = max(rowHeight, chipSize.height) + } + + return yOffset + rowHeight + } +} + +#Preview { + let viewController = UIViewController() + viewController.view.backgroundColor = .systemBackground + + let contentView = KeywordsContentConfiguration( + keywords: ["Swift", "UIKit", "프리뷰", "키워드", "자동 사이징", "SwiftUI", "Xcode"] + ).makeContentView() + contentView.translatesAutoresizingMaskIntoConstraints = false + viewController.view.addSubview(contentView) + + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor, constant: 20), + contentView.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor, constant: -20), + contentView.centerYAnchor.constraint(equalTo: viewController.view.centerYAnchor) + ]) + + return viewController +} diff --git a/Presentation/Sources/View/VoiceNote/Cells/KeywordsSkeletonCell.swift b/Presentation/Sources/View/VoiceNote/Cells/KeywordsSkeletonCell.swift new file mode 100644 index 00000000..fe7cca78 --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/KeywordsSkeletonCell.swift @@ -0,0 +1,106 @@ +import UIKit + +// MARK: - KeywordsSkeletonContentConfiguration + +struct KeywordsSkeletonContentConfiguration: UIContentConfiguration { + var beginOffset: CFTimeInterval = 0 + + func makeContentView() -> UIView & UIContentView { + KeywordsSkeletonContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> KeywordsSkeletonContentConfiguration { + self + } +} + +// MARK: - KeywordsSkeletonContentView + +final class KeywordsSkeletonContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + private let skeletonLine = SkeletonLineView() + + // MARK: - Init + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + backgroundColor = UIColor.gray100 + setupUI() + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: KeywordChipLabel.standardHeight) + } + + // MARK: - Setup + + private func setupUI() { + skeletonLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(skeletonLine) + + NSLayoutConstraint.activate([ + skeletonLine.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: Constant.keywordChipHorizontalPadding + ), + skeletonLine.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -Constant.keywordChipHorizontalPadding + ), + skeletonLine.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? KeywordsSkeletonContentConfiguration else { return } + skeletonLine.startAnimating(beginOffset: config.beginOffset) + } +} + +// MARK: - Preview + +#Preview { + let preview: UIView = { + let configs = [ + KeywordsSkeletonContentConfiguration(beginOffset: 0.0), + KeywordsSkeletonContentConfiguration(beginOffset: 0.2) + ] + + let stack = UIStackView(arrangedSubviews: configs.map { $0.makeContentView() }) + stack.axis = .vertical + stack.spacing = Constant.keywordChipLineSpacing + stack.alignment = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + + let container = UIView() + container.backgroundColor = .systemPink + container.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + + return container + }() + + preview +} diff --git a/Presentation/Sources/View/VoiceNote/Cells/MetadataCell.swift b/Presentation/Sources/View/VoiceNote/Cells/MetadataCell.swift new file mode 100644 index 00000000..d293606b --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/MetadataCell.swift @@ -0,0 +1,126 @@ +import UIKit + +// MARK: - MetadataContentConfiguration + +struct MetadataContentConfiguration: UIContentConfiguration { + var folderName: String = "" + var date: String = "" + var duration: String = "" + + func makeContentView() -> UIView & UIContentView { + MetadataContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> MetadataContentConfiguration { + self + } +} + +// MARK: - MetadataContentView + +final class MetadataContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + // MARK: - UI Components + + private let folderRow: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = Constant.metadataCellIconSpacing + return stack + }() + + private let folderIcon: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "folder")) + imageView.tintColor = .metadataLabel + return imageView + }() + + private let folderLabel: TypographyLabel = { + let label = TypographyLabel(typography: .body1) + label.textColor = .metadataLabel + return label + }() + + private let dateLabel: TypographyLabel = { + let label = TypographyLabel(typography: .body1) + label.textColor = .metadataLabel + return label + }() + + private let durationLabel: TypographyLabel = { + let label = TypographyLabel(typography: .body1) + label.textColor = .metadataLabel + return label + }() + + private let stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = Constant.metadataCellLineSpacing + return stack + }() + + // MARK: - Init + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setupUI() + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setupUI() { + folderRow.addArrangedSubview(folderIcon) + folderRow.addArrangedSubview(folderLabel) + + stackView.addArrangedSubview(folderRow) + stackView.addArrangedSubview(dateLabel) + stackView.addArrangedSubview(durationLabel) + stackView.setCustomSpacing(Constant.metadataCellSectionSpacing, after: folderRow) + + addSubview(stackView) + + stackView.translatesAutoresizingMaskIntoConstraints = false + folderIcon.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + folderIcon.widthAnchor.constraint(equalToConstant: Constant.metadataCellIconSize), + folderIcon.heightAnchor.constraint(equalToConstant: Constant.metadataCellIconSize), + + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? MetadataContentConfiguration else { return } + folderLabel.text = config.folderName + dateLabel.text = config.date + durationLabel.text = config.duration + } +} + +// MARK: - Preview + +#Preview { + let config = MetadataContentConfiguration( + folderName: "회의 노트", + date: "2026년 4월 20일 오후 2:30", + duration: "재생시간 12:34" + ) + MetadataContentView(configuration: config) +} diff --git a/Presentation/Sources/View/VoiceNote/Cells/ScriptCell.swift b/Presentation/Sources/View/VoiceNote/Cells/ScriptCell.swift new file mode 100644 index 00000000..258e2091 --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/ScriptCell.swift @@ -0,0 +1,176 @@ +import Core +import UIKit + +// MARK: - ScriptContentConfiguration + +struct ScriptContentConfiguration: UIContentConfiguration { + var sectionIndex: Int = 0 + var timestamp: TimeInterval = 0 + var text: String = "" + var isHighlighted: Bool = false + var isEditing: Bool = false + var searchQuery: String = "" + var currentMatchRange: NSRange? + var onTextEdited: ((Int, String) -> Void)? + var onTextHeightChanged: (() -> Void)? + + func makeContentView() -> UIView & UIContentView { + ScriptContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> ScriptContentConfiguration { + self + } +} + +// MARK: - ScriptContentView + +final class ScriptContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + // MARK: - UI Components + + private let timeLabel: TypographyLabel = { + let label = TypographyLabel(typography: .caption) + label.textColor = UIColor.gray600 + return label + }() + + private lazy var textView: UITextView = { + let spacing = Constant.scriptCellSpacing + let textView = TypographyTextView(typography: .body1) + textView.textColor = UIColor.gray950 + textView.isEditable = false + textView.isSelectable = false + textView.isScrollEnabled = false + textView.textContainerInset = UIEdgeInsets(top: spacing, left: spacing, bottom: spacing, right: spacing) + textView.textContainer.lineFragmentPadding = 0 + textView.layer.cornerRadius = Constant.scriptCellCornerRadius + textView.delegate = self + return textView + }() + + // MARK: - Init + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setupUI() + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setupUI() { + addSubview(timeLabel) + addSubview(textView) + + for subview in [timeLabel, textView] { + subview.translatesAutoresizingMaskIntoConstraints = false + } + + let spacing = Constant.scriptCellSpacing + NSLayoutConstraint.activate([ + timeLabel.topAnchor.constraint(equalTo: topAnchor), + timeLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: spacing), + timeLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + + textView.topAnchor.constraint(equalTo: timeLabel.bottomAnchor), + textView.leadingAnchor.constraint(equalTo: leadingAnchor), + textView.trailingAnchor.constraint(equalTo: trailingAnchor), + textView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? ScriptContentConfiguration else { return } + timeLabel.text = config.timestamp.durationString + + textView.isEditable = config.isEditing + textView.isSelectable = config.isEditing + textView.isUserInteractionEnabled = config.isEditing + textView.backgroundColor = config.isHighlighted ? .scriptCellHighlight : .clear + + // 편집 모드와 검색 모드는 상호 배타적이지만, 안전을 위해 편집 중에는 하이라이트를 적용하지 않는다. + if config.isEditing || config.searchQuery.isEmpty { + textView.text = config.text + } else { + textView.attributedText = config.text.highlighted( + query: config.searchQuery, + baseAttributes: baseTextAttributes, + highlightBackgroundColor: UIColor.point700, + focusedRange: config.currentMatchRange, + focusedHighlightBackgroundColor: .warning2 + ) + } + } + + private var baseTextAttributes: [NSAttributedString.Key: Any] { + var attributes = Typography.body1.textAttributes + attributes[.foregroundColor] = UIColor.gray950 + return attributes + } +} + +// MARK: - UITextViewDelegate + +extension ScriptContentView: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + guard let config = configuration as? ScriptContentConfiguration else { return } + let text = textView.text ?? "" + config.onTextEdited?(config.sectionIndex, text) + config.onTextHeightChanged?() + } +} + +// MARK: - Preview + +#Preview { + let normalConfig = ScriptContentConfiguration( + sectionIndex: 0, + timestamp: 0, + text: "일반 상태의 스크립트 텍스트입니다." + ) + let highlightedConfig = ScriptContentConfiguration( + sectionIndex: 1, + timestamp: 12, + text: "현재 재생 중인 하이라이트 상태의 스크립트입니다.", + isHighlighted: true + ) + let editingConfig = ScriptContentConfiguration( + sectionIndex: 2, + timestamp: 24, + text: "편집 모드의 스크립트 — 탭하여 수정할 수 있습니다.", + isEditing: true + ) + + let normalCell = ScriptContentView(configuration: normalConfig) + let highlightedCell = ScriptContentView(configuration: highlightedConfig) + let editingCell = ScriptContentView(configuration: editingConfig) + + let stack = UIStackView(arrangedSubviews: [normalCell, highlightedCell, editingCell]) + stack.axis = .vertical + stack.spacing = 16 + stack.translatesAutoresizingMaskIntoConstraints = false + + let container = UIView() + container.backgroundColor = .gray100 + container.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + + return container +} diff --git a/Presentation/Sources/View/VoiceNote/Cells/ScriptSkeletonCell.swift b/Presentation/Sources/View/VoiceNote/Cells/ScriptSkeletonCell.swift new file mode 100644 index 00000000..064f4ed1 --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/ScriptSkeletonCell.swift @@ -0,0 +1,60 @@ +import UIKit + +// MARK: - ScriptSkeletonContentConfiguration + +struct ScriptSkeletonContentConfiguration: UIContentConfiguration { + var beginOffset: CFTimeInterval = 0 + + func makeContentView() -> UIView & UIContentView { + ScriptSkeletonContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> ScriptSkeletonContentConfiguration { + self + } +} + +// MARK: - ScriptSkeletonContentView + +final class ScriptSkeletonContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + private let skeletonLine = SkeletonLineView() + + // MARK: - Init + + init(configuration: UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setupUI() + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setupUI() { + skeletonLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(skeletonLine) + + NSLayoutConstraint.activate([ + skeletonLine.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constant.scriptCellSpacing), + skeletonLine.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constant.scriptCellSpacing), + skeletonLine.topAnchor.constraint(equalTo: topAnchor), + skeletonLine.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // MARK: - Apply + + private func apply(configuration: UIContentConfiguration) { + guard let config = configuration as? ScriptSkeletonContentConfiguration else { return } + skeletonLine.startAnimating(beginOffset: config.beginOffset) + } +} diff --git a/Presentation/Sources/View/VoiceNote/Cells/WarningCell.swift b/Presentation/Sources/View/VoiceNote/Cells/WarningCell.swift new file mode 100644 index 00000000..ee9250e0 --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/Cells/WarningCell.swift @@ -0,0 +1,159 @@ +import UIKit + +// MARK: - WarningContentConfiguration + +public struct WarningContentConfiguration: UIContentConfiguration { + public let title: String + public let subTitle: String + public let buttonTitle: String? + public let symbolIconName: String + public let action: (() -> Void)? + + public init( + title: String, + subTitle: String, + buttonTitle: String? = nil, + symbolIconName: String, + action: (() -> Void)? = nil + ) { + self.title = title + self.subTitle = subTitle + self.buttonTitle = buttonTitle + self.symbolIconName = symbolIconName + self.action = action + } + + public func makeContentView() -> any UIView & UIContentView { + WarningContentView(configuration: self) + } + + public func updated(for state: any UIConfigurationState) -> WarningContentConfiguration { + self + } +} + +// MARK: - WarningContentView + +public final class WarningContentView: UIView, UIContentView { + public var configuration: any UIContentConfiguration { + didSet { apply(configuration: configuration) } + } + + // MARK: - UI Components + + private let warningIconView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFit + return iv + }() + + private let titleLabel: TypographyLabel = { + let label = TypographyLabel(typography: .title2, alignment: .center) + label.textColor = UIColor.gray950 + label.numberOfLines = 0 + return label + }() + + private let subTitle: TypographyLabel = { + let label = TypographyLabel(typography: .body2, alignment: .center) + label.textColor = UIColor.gray950 + label.numberOfLines = 0 + return label + }() + + private let actionButton: GlassButton = { + let button: GlassButton = .default("") + button.setCapsuleCornerRadius() + button.widthAnchor.constraint(equalToConstant: 200).isActive = true + button.heightAnchor.constraint(equalToConstant: 54).isActive = true + return button + }() + + private let containerStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 8 + stack.isLayoutMarginsRelativeArrangement = true + stack.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 100, + leading: 24, + bottom: 100, + trailing: 24 + ) + return stack + }() + + // MARK: - Init + + public init(configuration: any UIContentConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + setupUI() + apply(configuration: configuration) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setupUI() { + containerStack.addArrangedSubview(warningIconView) + containerStack.addArrangedSubview(titleLabel) + containerStack.setCustomSpacing(16, after: titleLabel) + containerStack.addArrangedSubview(subTitle) + containerStack.setCustomSpacing(24, after: subTitle) + containerStack.addArrangedSubview(actionButton) + addSubview(containerStack) + + containerStack.translatesAutoresizingMaskIntoConstraints = false + + let topConstraint = containerStack.topAnchor.constraint(equalTo: topAnchor) + let bottomConstraint = containerStack.bottomAnchor.constraint(equalTo: bottomAnchor) + + // UIKit 셀 초기화/디큐 시점의 임시 52pt 높이 제약조건과의 충돌을 방지하기 위해 세로 제약의 우선순위를 미세하게 낮춥니다. + topConstraint.priority = .init(999) + bottomConstraint.priority = .init(999) + + NSLayoutConstraint.activate([ + containerStack.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStack.trailingAnchor.constraint(equalTo: trailingAnchor), + topConstraint, + bottomConstraint + ]) + } + + // MARK: - Apply + + private func apply(configuration: any UIContentConfiguration) { + guard let config = configuration as? WarningContentConfiguration else { return } + + titleLabel.text = config.title + subTitle.text = config.subTitle + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48, weight: .semibold) + warningIconView.image = UIImage(systemName: config.symbolIconName, withConfiguration: symbolConfig) + warningIconView.tintColor = .systemOrange + + if let buttonTitle = config.buttonTitle { + actionButton.configure( + buttonTitle, + typography: .subtitle1, + border: .init(color: .color(.gray600), width: Constant.borderWidth), + backgroundColor: .color(UIColor.point200.withAlphaComponent(Constant.backgroundOpacity)), + foregroundColor: UIColor.gray900 + ) + + // 버튼 터치 액션 바인딩 및 중복 등록 방지 처리 + actionButton.removeTarget(nil, action: nil, for: .allEvents) + actionButton.addAction(UIAction { _ in + config.action?() + }, for: .touchUpInside) + } else { + actionButton.isHidden = true + } + } +} diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift new file mode 100644 index 00000000..09418a9e --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteScriptViewController.swift @@ -0,0 +1,310 @@ +import Observation +import UIKit + +final class VoiceNoteScriptViewController: UICollectionViewController { + private let viewModel: VoiceNoteViewModel + + private lazy var dataSource = makeDataSource() + + init(viewModel: VoiceNoteViewModel) { + self.viewModel = viewModel + super.init(collectionViewLayout: UICollectionViewFlowLayout()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + override func viewDidLoad() { + super.viewDidLoad() + collectionView.backgroundColor = .clear + collectionView.showsVerticalScrollIndicator = false + collectionView.keyboardDismissMode = .interactive + collectionView.collectionViewLayout = makeLayout() + + applySnapshot() + observeTranscriptSections() + observePlayingParagraph() + observeEditingMode() + observeSearchState() + observeAnalysisState() + } + + /// 지정한 매치 위치의 스크립트 섹션으로 컬렉션을 스크롤합니다. + func scrollToMatch(_ match: VoiceNoteSearchMatch) { + guard case .script(let sectionIndex) = match.location else { return } + let indexPath = IndexPath(item: sectionIndex, section: 0) + guard dataSource.itemIdentifier(for: indexPath) != nil else { return } + collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: true) + } +} + +// MARK: - Layout + +private extension VoiceNoteScriptViewController { + func makeLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in + guard let self else { return nil } + guard let sectionType = dataSource.sectionIdentifier(for: sectionIndex) else { return nil } + + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + config.showsSeparators = false + + if sectionType == .failure { + config.headerMode = .none + } else { + config.headerMode = .supplementary + } + + let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) + + for item in section.boundarySupplementaryItems { + item.pinToVisibleBounds = false + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: nil, top: .fixed(22), + trailing: nil, bottom: nil + ) + item.contentInsets = NSDirectionalEdgeInsets( + top: 0, leading: 20, bottom: 0, trailing: 20 + ) + } + + section.contentInsets = NSDirectionalEdgeInsets( + top: 12, leading: 20, bottom: 0, trailing: 20 + ) + section.interGroupSpacing = isShowingSkeleton ? Constant.scriptCellSpacing : 16 + + return section + } + } +} + +// MARK: - DataSource + +private extension VoiceNoteScriptViewController { + var isShowingSkeleton: Bool { + switch viewModel.voiceNote.analysisState { + case .pending, .transcribing: + return true + case .transcribed, .summarizing, .regenerating, .completed, + .transcriptionFailed, .summarizationFailed: + return false + } + } + + func makeDataSource() -> UICollectionViewDiffableDataSource { + let scriptCellReg = UICollectionView.CellRegistration { [weak self] cell, _, item in + guard let self, case .script(let index) = item else { return } + let section = viewModel.scriptSections[index] + let isHighlighted = viewModel.playingSectionIndex == index + + let focusedRange: NSRange? = { + guard let match = self.viewModel.currentMatch, + case .script(let sectionIndex) = match.location, + sectionIndex == index else { return nil } + return match.range + }() + + cell.contentConfiguration = ScriptContentConfiguration( + sectionIndex: index, + timestamp: section.timestamp, + text: section.text, + isHighlighted: isHighlighted, + isEditing: viewModel.editingMode == .script, + searchQuery: viewModel.searchQuery, + currentMatchRange: focusedRange, + onTextEdited: { [weak self] sectionIndex, text in + self?.viewModel.updateScriptSection(sectionIndex: sectionIndex, text: text) + }, + onTextHeightChanged: { [weak self] in + guard let self else { return } + UIView.performWithoutAnimation { + self.collectionView.collectionViewLayout.invalidateLayout() + } + } + ) + } + + let scriptSkeletonCellReg = UICollectionView.CellRegistration { cell, _, item in + guard case .scriptSkeleton(_, let beginOffset) = item else { return } + cell.contentConfiguration = ScriptSkeletonContentConfiguration(beginOffset: beginOffset) + } + + let warningCellReg = UICollectionView.CellRegistration { cell, _, item in + guard case .failure = item else { return } + cell.contentConfiguration = WarningContentConfiguration( + title: "요약 할 수 있는 음성이 기록되지 않았어요", + subTitle: "인식된 음성이 없어서\n전사를 실행할 수 없어요", + symbolIconName: "progress.indicator" + ) + } + + let dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, item in + switch item { + case .script: + return collectionView.dequeueConfiguredReusableCell(using: scriptCellReg, for: indexPath, item: item) + case .scriptSkeleton: + return collectionView.dequeueConfiguredReusableCell( + using: scriptSkeletonCellReg, for: indexPath, item: item + ) + case .failure: + return collectionView.dequeueConfiguredReusableCell( + using: warningCellReg, for: indexPath, item: item + ) + } + } + + let headerReg = makeHeaderRegistration() + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in + collectionView.dequeueConfiguredReusableSupplementary(using: headerReg, for: indexPath) + } + + return dataSource + } + + func makeHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { header, _, _ in + header.configure(title: "스크립트") + } + } + + func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + let isFailed = viewModel.voiceNote.analysisState == .transcriptionFailed + + if isFailed { + snapshot.appendSections([.failure]) + snapshot.appendItems([.failure], toSection: .failure) + } else { + snapshot.appendSections([.scripts]) + + let items: [Item] = if isShowingSkeleton { + (0 ..< 30).map { idx in + .scriptSkeleton(index: idx, beginOffset: Double(idx % 3) * 0.2) + } + } else { + viewModel.scriptSections.indices.map { Item.script(index: $0) } + } + + snapshot.appendItems(items, toSection: .scripts) + snapshot.reconfigureItems(items) + } + dataSource.apply(snapshot, animatingDifferences: true) + } + + func reconfigureScripts() { + var snapshot = dataSource.snapshot() + guard snapshot.sectionIdentifiers.contains(.scripts) else { return } + let scriptItems = snapshot.itemIdentifiers(inSection: .scripts) + guard !scriptItems.isEmpty else { return } + snapshot.reconfigureItems(scriptItems) + dataSource.apply(snapshot, animatingDifferences: false) + } +} + +// MARK: - Observations + +private extension VoiceNoteScriptViewController { + func observeTranscriptSections() { + withObservationTracking { + _ = viewModel.voiceNote.transcript?.sections + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.applySnapshot() + self.observeTranscriptSections() + } + } + } + + func observeAnalysisState() { + withObservationTracking { + _ = viewModel.voiceNote.analysisState + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.collectionView.collectionViewLayout.invalidateLayout() + self.applySnapshot() + self.observeAnalysisState() + } + } + } + + func observePlayingParagraph() { + withObservationTracking { + _ = viewModel.playingSectionIndex + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.reconfigureScripts() + self.observePlayingParagraph() + } + } + } + + func observeEditingMode() { + withObservationTracking { + _ = viewModel.editingMode + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.reconfigureScripts() + self.observeEditingMode() + } + } + } + + func observeSearchState() { + withObservationTracking { + _ = viewModel.searchQuery + _ = viewModel.currentMatchIndex + _ = viewModel.currentPage + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.reconfigureScripts() + self.observeSearchState() + } + } + } +} + +// MARK: - UICollectionViewDelegate + +extension VoiceNoteScriptViewController { + override func collectionView( + _ collectionView: UICollectionView, + shouldSelectItemAt indexPath: IndexPath + ) -> Bool { + viewModel.editingMode != .script && !viewModel.searchMode + } + + override func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + defer { collectionView.deselectItem(at: indexPath, animated: false) } + guard case .script(let index) = dataSource.itemIdentifier(for: indexPath) else { return } + let timestamp = viewModel.scriptSections[index].timestamp + viewModel.scriptTimestampTapped(timestamp) + } +} + +// MARK: - Section / Item + +extension VoiceNoteScriptViewController { + enum Section: Int, CaseIterable { + case scripts + case failure + } + + enum Item: Hashable { + case script(index: Int) + case scriptSkeleton(index: Int, beginOffset: CFTimeInterval) + case failure + } +} diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteSectionHeaderView.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteSectionHeaderView.swift new file mode 100644 index 00000000..b14e094b --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteSectionHeaderView.swift @@ -0,0 +1,96 @@ +import UIKit + +final class VoiceNoteSectionHeaderView: UICollectionReusableView { + static let reuseIdentifier = "FileDetailSectionHeaderView" + + // MARK: - UI Components + + private let titleLabel: TypographyLabel = { + let label = TypographyLabel(typography: .title2) + label.textColor = .gray950 + return label + }() + + private let contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + return stack + }() + + private var onTrailingTap: (() -> Void)? + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + // MARK: - Setup + + private func setupUI() { + contentStack.addArrangedSubview(titleLabel) + addSubview(contentStack) + + contentStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contentStack.topAnchor.constraint(equalTo: topAnchor), + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor), + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + } + + // MARK: - Configure + + func configure(title: String, trailingView: UIView? = nil, onTrailingTap: (() -> Void)? = nil) { + titleLabel.text = title + self.onTrailingTap = onTrailingTap + setTrailingView(trailingView) + } + + // MARK: - Private + + private func setTrailingView(_ view: UIView?) { + contentStack.arrangedSubviews + .filter { $0 !== titleLabel } + .forEach { $0.removeFromSuperview() } + + guard let view else { return } + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + contentStack.addArrangedSubview(spacer) + contentStack.addArrangedSubview(view) + + if onTrailingTap != nil { + view.isUserInteractionEnabled = true + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(trailingTapped))) + } + } + + @objc + private func trailingTapped() { + onTrailingTap?() + } +} + +#Preview("trailingView 있음") { + let header = VoiceNoteSectionHeaderView() + let chip = RegenerationChip(state: .idle) + header.configure(title: "핵심 포인트", trailingView: chip) + header.backgroundColor = .black + return header +} + +#Preview("trailingView 없음") { + let header = VoiceNoteSectionHeaderView() + header.configure(title: "키워드") + header.backgroundColor = .black + return header +} diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift new file mode 100644 index 00000000..80638112 --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteSummaryViewController.swift @@ -0,0 +1,383 @@ +import Observation +import UIKit + +final class VoiceNoteSummaryViewController: UICollectionViewController { + private let viewModel: VoiceNoteViewModel + + private lazy var dataSource = makeDataSource() + + init(viewModel: VoiceNoteViewModel) { + self.viewModel = viewModel + super.init(collectionViewLayout: UICollectionViewFlowLayout()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + override func viewDidLoad() { + super.viewDidLoad() + collectionView.backgroundColor = .clear + collectionView.showsVerticalScrollIndicator = false + collectionView.keyboardDismissMode = .interactive + collectionView.collectionViewLayout = makeLayout() + + applySnapshot() + observeAnalysisState() + observeSearchQuery() + observeCurrentMatch() + } + + func scrollToMatch(_ match: VoiceNoteSearchMatch) { + let indexPath: IndexPath + switch match.location { + case .keyPoint(let index): + indexPath = IndexPath(item: index, section: Section.keyPoints.rawValue) + case .keyword: + indexPath = IndexPath(item: 0, section: Section.keywords.rawValue) + case .script: + return + } + guard dataSource.itemIdentifier(for: indexPath) != nil else { return } + collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: true) + } +} + +// MARK: - Layout + +private extension VoiceNoteSummaryViewController { + func makeLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in + guard let self else { return nil } + guard let sectionType = dataSource.sectionIdentifier(for: sectionIndex) else { return nil } + + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + config.showsSeparators = false + + if sectionType == .metadata || sectionType == .failure { + config.headerMode = .none + } else { + config.headerMode = .supplementary + } + + let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) + + let headerTop: CGFloat = switch sectionType { + case .keyPoints: Constant.summarySectionKeyPointsHeaderTop + case .keywords: Constant.summarySectionKeywordsHeaderTop + default: 0 + } + for item in section.boundarySupplementaryItems { + item.pinToVisibleBounds = false + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: nil, top: .fixed(headerTop), + trailing: nil, bottom: nil + ) + item.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: Constant.summarySectionHorizontalInset, + bottom: 0, + trailing: Constant.summarySectionHorizontalInset + ) + } + + let cellTop: CGFloat = switch sectionType { + case .metadata: Constant.summarySectionMetadataTopInset + case .keyPoints: Constant.summarySectionKeyPointsTopInset + case .keywords: Constant.summarySectionKeywordsTopInset + default: 0 + } + section.contentInsets = NSDirectionalEdgeInsets( + top: cellTop, + leading: Constant.summarySectionHorizontalInset, + bottom: 0, + trailing: Constant.summarySectionHorizontalInset + ) + + section.interGroupSpacing = switch sectionType { + case .keyPoints: Constant.summarySectionKeyPointsGroupSpacing + case .keywords: Constant.keywordChipLineSpacing + default: 0 + } + + return section + } + } +} + +// MARK: - DataSource + +private extension VoiceNoteSummaryViewController { + func makeDataSource() -> UICollectionViewDiffableDataSource { + let metadataCellReg = UICollectionView.CellRegistration { [weak self] cell, _, _ in + cell.contentConfiguration = MetadataContentConfiguration( + folderName: self?.viewModel.folderName ?? "", + date: self?.viewModel.metadataText1 ?? "", + duration: self?.viewModel.metadataText2 ?? "" + ) + } + + let keyPointCellReg = UICollectionView + .CellRegistration { [weak self] cell, indexPath, item in + guard case .keyPoint(let number, let text) = item else { return } + cell.contentConfiguration = KeyPointContentConfiguration( + number: number, + text: text, + highlightRanges: self?.viewModel.highlightRanges(in: text) ?? [], + focusedRange: self?.viewModel.focusedKeyPointRange(at: indexPath.item) + ) + } + + let keywordsCellReg = UICollectionView.CellRegistration { [weak self] cell, _, _ in + let keywords = self?.viewModel.keywords ?? [] + let keywordMatch = self?.viewModel.focusedKeywordMatch() + cell.contentConfiguration = KeywordsContentConfiguration( + keywords: keywords, + keywordHighlightRanges: keywords.map { self?.viewModel.highlightRanges(in: $0) ?? [] }, + focusedKeywordIndex: keywordMatch?.index, + focusedRange: keywordMatch?.range + ) + } + + let keyPointSkeletonCellReg = UICollectionView.CellRegistration { cell, _, item in + guard case .keyPointSkeleton(let number, let beginOffset) = item else { return } + cell.contentConfiguration = KeyPointSkeletonContentConfiguration( + number: number, + beginOffset: beginOffset + ) + } + + let keywordsSkeletonCellReg = UICollectionView.CellRegistration { cell, _, item in + guard case .keywordsSkeleton(let beginOffset) = item else { return } + cell.contentConfiguration = KeywordsSkeletonContentConfiguration(beginOffset: beginOffset) + } + + let warningCellReg = UICollectionView + .CellRegistration { [weak self] cell, _, item in + guard case .failure(let isSupported) = item, let self else { return } + + let title: String + let subTitle: String + let buttonTitle: String + let action: () -> Void + + if !isSupported { + title = "요약을 생성하지 못했어요" + subTitle = "AI 요약 기능이\n현재 기기에서는 지원되지 않습니다" + buttonTitle = "스크립트" + action = { [weak self] in + self?.viewModel.updateCurrentPage(.script) + } + } else { + title = "요약을 생성하지 못했어요" + subTitle = "일시적인 오류가 발생했어요\n잠시 후 다시 시도해주세요" + buttonTitle = "재 생성" + action = { [weak self] in + self?.viewModel.regenerateSummary() + } + } + + cell.contentConfiguration = WarningContentConfiguration( + title: title, + subTitle: subTitle, + buttonTitle: buttonTitle, + symbolIconName: "exclamationmark.triangle.fill", + action: action + ) + } + + let dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, item in + switch item { + case .metadata: + return collectionView.dequeueConfiguredReusableCell(using: metadataCellReg, for: indexPath, item: item) + case .keyPoint: + return collectionView.dequeueConfiguredReusableCell(using: keyPointCellReg, for: indexPath, item: item) + case .keywords: + return collectionView.dequeueConfiguredReusableCell(using: keywordsCellReg, for: indexPath, item: item) + case .keyPointSkeleton: + return collectionView.dequeueConfiguredReusableCell( + using: keyPointSkeletonCellReg, for: indexPath, item: item + ) + case .keywordsSkeleton: + return collectionView.dequeueConfiguredReusableCell( + using: keywordsSkeletonCellReg, for: indexPath, item: item + ) + case .failure: + return collectionView.dequeueConfiguredReusableCell( + using: warningCellReg, for: indexPath, item: item + ) + } + } + + let headerReg = makeHeaderRegistration() + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in + collectionView.dequeueConfiguredReusableSupplementary(using: headerReg, for: indexPath) + } + + return dataSource + } + + func makeHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak self] header, _, indexPath in + guard let self, + let section = dataSource.sectionIdentifier(for: indexPath.section), + let title = section.headerTitle else { return } + + if section == .keyPoints, let state = regenerationChipState { + let chip = RegenerationChip(state: state) + let onTap: (() -> Void)? = state == .loading ? nil : { [weak self] in + self?.viewModel.regenerateSummary() + } + header.configure(title: title, trailingView: chip, onTrailingTap: onTap) + } else { + header.configure(title: title) + } + } + } + + var regenerationChipState: RegenerationChip.State? { + guard !viewModel.isTrashMode else { return nil } + switch viewModel.voiceNote.analysisState { + // 첫 분석 중에는 요약 섹션이 비어 있어 칩을 숨긴다. + case .pending, .summarizing, .transcribed, .transcribing, .transcriptionFailed: return nil + case .regenerating: return .loading + case .completed: return viewModel.isSummaryOutdated ? .outdated : .idle + case .summarizationFailed: return .idle + } + } + + var isShowingSkeleton: Bool { + switch viewModel.voiceNote.analysisState { + case .pending, .regenerating, .summarizing, .transcribed, .transcribing: + return true + case .completed, .summarizationFailed, .transcriptionFailed: + return false + } + } + + func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + let isFailed = viewModel.voiceNote.analysisState == .summarizationFailed || !viewModel.isMLXModelSupported + + if isFailed { + snapshot.appendSections([.metadata, .failure]) + snapshot.appendItems([.metadata], toSection: .metadata) + snapshot.appendItems([.failure(isMLXModelSupported: viewModel.isMLXModelSupported)], toSection: .failure) + + dataSource.apply(snapshot, animatingDifferences: true) + } else { + snapshot.appendSections([.metadata, .keyPoints, .keywords]) + + let metadataItems: [Item] = [.metadata] + snapshot.appendItems(metadataItems, toSection: .metadata) + + let keyPointItems: [Item] + let keywordItems: [Item] + + if isShowingSkeleton { + keyPointItems = (0 ..< Constant.skeletonKeyPointCount).map { + .keyPointSkeleton(number: $0 + 1, beginOffset: Double($0) * Constant.skeletonStaggerOffset) + } + keywordItems = (0 ..< Constant.skeletonKeywordCount).map { + .keywordsSkeleton(beginOffset: Double($0) * Constant.skeletonStaggerOffset) + } + } else { + keyPointItems = viewModel.keyPoints.map { Item.keyPoint(number: $0.number, text: $0.text) } + keywordItems = [.keywords] + } + + snapshot.appendItems(keyPointItems, toSection: .keyPoints) + snapshot.appendItems(keywordItems, toSection: .keywords) + + snapshot.reconfigureItems(metadataItems + keywordItems) + snapshot.reloadSections([.keyPoints]) + dataSource.apply(snapshot, animatingDifferences: true) + } + } +} + +// MARK: - Observations + +private extension VoiceNoteSummaryViewController { + func observeAnalysisState() { + withObservationTracking { + _ = viewModel.voiceNote.analysisState + _ = viewModel.isMLXModelSupported + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.applySnapshot() + self.collectionView.collectionViewLayout.invalidateLayout() + self.observeAnalysisState() + } + } + } + + func observeSearchQuery() { + withObservationTracking { + _ = viewModel.searchQuery + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.reconfigureForSearch() + self.observeSearchQuery() + } + } + } + + func observeCurrentMatch() { + withObservationTracking { + _ = viewModel.currentMatchIndex + _ = viewModel.currentPage + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.reconfigureForSearch() + self.observeCurrentMatch() + } + } + } + + func reconfigureForSearch() { + var snapshot = dataSource.snapshot() + let items = snapshot.itemIdentifiers + guard !items.isEmpty else { return } + snapshot.reconfigureItems(items.filter { + if case .metadata = $0 { return false } + return true + }) + dataSource.apply(snapshot, animatingDifferences: false) + } +} + +// MARK: - Section / Item + +extension VoiceNoteSummaryViewController { + enum Section: Int, CaseIterable { + case metadata + case keyPoints + case keywords + case failure + + var headerTitle: String? { + switch self { + case .metadata, .failure: return nil + case .keyPoints: return "핵심 포인트" + case .keywords: return "키워드" + } + } + } + + enum Item: Hashable { + case metadata + case keyPoint(number: Int, text: String) + case keywords + case keyPointSkeleton(number: Int, beginOffset: CFTimeInterval) + case keywordsSkeleton(beginOffset: CFTimeInterval) + case failure(isMLXModelSupported: Bool) + } +} diff --git a/Presentation/Sources/View/VoiceNote/VoiceNoteViewController.swift b/Presentation/Sources/View/VoiceNote/VoiceNoteViewController.swift new file mode 100644 index 00000000..16316ba1 --- /dev/null +++ b/Presentation/Sources/View/VoiceNote/VoiceNoteViewController.swift @@ -0,0 +1,422 @@ +import Domain +import UIKit + +public final class VoiceNoteViewController: ViewController, Alertable { + fileprivate typealias Page = VoiceNoteViewModel.Page + + private let viewModel: VoiceNoteViewModel + + // MARK: - UI Components + + private let navigationBar = VoiceNoteNavigationBar() + private let playerView = AudioPlayerView() + private let segmentedControl = UnderlineSegmentedControl(items: Page.allCases.map(\.title)) + private let matchAccessoryBar: VoiceNoteMatchAccessoryBar = { + let bar = VoiceNoteMatchAccessoryBar() + bar.isHidden = true + return bar + }() + + private lazy var dimOverlayView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.dimBackground + view.isHidden = true + let tap = UITapGestureRecognizer(target: self, action: #selector(dimOverlayTapped)) + view.addGestureRecognizer(tap) + + return view + }() + + private lazy var pageViewController: UIPageViewController = { + let pvc = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) + pvc.dataSource = self + pvc.delegate = self + pvc.setViewControllers([summaryViewController], direction: .forward, animated: false) + return pvc + }() + + private lazy var summaryViewController = VoiceNoteSummaryViewController(viewModel: viewModel) + private lazy var scriptViewController = VoiceNoteScriptViewController(viewModel: viewModel) + private lazy var pages: [UIViewController] = [summaryViewController, scriptViewController] + + private let contentBottomGuide = UILayoutGuide() + private lazy var contentBottomToPlayerTop = contentBottomGuide.topAnchor.constraint(equalTo: playerView.topAnchor) + private lazy var contentBottomToViewBottom = contentBottomGuide.topAnchor.constraint(equalTo: view.bottomAnchor) + private lazy var pageTopToSegmentBottom = pageViewController.view.topAnchor.constraint( + equalTo: segmentedControl.bottomAnchor + ) + private lazy var pageTopToSafeArea = pageViewController.view.topAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.topAnchor + ) + + // MARK: - Init + + public init(viewModel: VoiceNoteViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + // MARK: - Lifecycle + + override public func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupBindings() + viewModel.onAppear() + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + viewModel.onDisappear() + } +} + +// MARK: - Setup + +private extension VoiceNoteViewController { + func setupUI() { + updateNavigationBarAppearance(isTransparent: false) + addChild(pageViewController) + pageViewController.didMove(toParent: self) + + view.addSubview(segmentedControl) + view.addSubview(pageViewController.view) + view.addSubview(playerView) + + view.addSubview(dimOverlayView) + view.addSubview(matchAccessoryBar) + + setupConstraints() + setupNavigationBar() + setupTabBar() + setupPlayerView() + setupMatchAccessoryBar() + } + + func setupConstraints() { + for subview in [ + pageViewController.view, + playerView, + segmentedControl, + dimOverlayView, + matchAccessoryBar + ] { + subview?.translatesAutoresizingMaskIntoConstraints = false + } + + view.addLayoutGuide(contentBottomGuide) + + NSLayoutConstraint.activate([ + segmentedControl.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + segmentedControl.leadingAnchor.constraint(equalTo: view.leadingAnchor), + segmentedControl.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + pageViewController.view.bottomAnchor.constraint(equalTo: contentBottomGuide.topAnchor), + + playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + contentBottomGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentBottomGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + contentBottomGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + dimOverlayView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + dimOverlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dimOverlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + dimOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + matchAccessoryBar.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: Constant.matchAccessoryBarHorizontalMargin + ), + matchAccessoryBar.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -Constant.matchAccessoryBarHorizontalMargin + ), + matchAccessoryBar.bottomAnchor.constraint( + equalTo: view.keyboardLayoutGuide.topAnchor, + constant: -Constant.matchAccessoryBarKeyboardSpacing + ) + ]) + + pageTopToSegmentBottom.isActive = true + contentBottomToPlayerTop.isActive = true + } + + @objc + func dimOverlayTapped() { + viewModel.doneTitleEditing(title: navigationBar.titleText) + } + + func setupNavigationBar() { + navigationBar.onBack = { [weak self] in + self?.viewModel.pop() + } + navigationBar.onEditCancel = { [weak self] in + self?.viewModel.cancelEditing() + } + navigationBar.onDoneTitle = { [weak self] title in + self?.viewModel.doneTitleEditing(title: title) + } + navigationBar.onDoneScript = { [weak self] in + self?.view.makeToast(type: .normal, "스크립트가 수정되었어요.") + self?.viewModel.doneScriptEditing() + } + + // TODO: - 완료 핸들러 안해도 될듯. + navigationBar.onMove = { [weak self] in + self?.viewModel.moveVoiceNote { [weak self] name in + self?.view.makeToast(type: .normal, "`\(name)` 폴더로 이동됐어요.") + } + } + navigationBar.onEditScript = { [weak self] in + self?.viewModel.enterScriptEditing() + } + navigationBar.onDelete = { [weak self] in + self?.viewModel.deleteVoiceNote() + } + navigationBar.onTapTitle = { [weak self] in + self?.viewModel.enterTitleEditing() + } + navigationBar.onSearchEnter = { [weak self] in + self?.viewModel.enterSearchMode() + } + navigationBar.onSearchQuery = { [weak self] query in + self?.viewModel.updateSearchQuery(query) + } + navigationBar.onSearchClose = { [weak self] in + self?.viewModel.exitSearchMode() + } + + navigationBar.apply(to: navigationItem, title: viewModel.title, isTrashMode: viewModel.isTrashMode) + } + + func setupTabBar() { + segmentedControl.onSegmentSelected = { [weak self] index in + guard let self, let page = Page(rawValue: index) else { return } + viewModel.updateCurrentPage(page) + } + } + + func setupPlayerView() { + playerView.onPlayPause = { [weak self] in self?.viewModel.playPause() } + playerView.onRewind = { [weak self] in self?.viewModel.rewind() } + playerView.onForward = { [weak self] in self?.viewModel.forward() } + playerView.onSeekBegan = { [weak self] in self?.viewModel.seekBegan() } + playerView.onSeekEnded = { [weak self] time in self?.viewModel.seekEnded(time) } + } + + func setupMatchAccessoryBar() { + matchAccessoryBar.onPrev = { [weak self] in + self?.viewModel.previousMatch() + } + matchAccessoryBar.onNext = { [weak self] in + self?.viewModel.nextMatch() + } + } + + func setupBindings() { + observePlaybackState() + observeErrorMessage() + observeEditingState() + observeCurrentPage() + observeSearchState() + } + + func observePlaybackState() { + withObservationTracking { + _ = viewModel.currentPlaybackState + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.playerView.apply(self.viewModel.currentPlaybackState) + self.observePlaybackState() + } + } + } + + func observeErrorMessage() { + withObservationTracking { + _ = viewModel.errorMessage + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + if let message = self.viewModel.errorMessage { + self.showAlert(title: "오류", message: message) { [weak self] in + self?.viewModel.dismissError() + } + } + self.observeErrorMessage() + } + } + } + + func observeEditingState() { + withObservationTracking { + _ = viewModel.editingMode + _ = viewModel.hasScriptEdits + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.applyEditingMode() + self.observeEditingState() + } + } + } + + func observeCurrentPage() { + withObservationTracking { + _ = viewModel.currentPage + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.applyCurrentPage(self.viewModel.currentPage) + self.observeCurrentPage() + } + } + } + + func observeSearchState() { + withObservationTracking { + _ = viewModel.searchMode + _ = viewModel.searchQuery + _ = viewModel.currentMatchIndex + } onChange: { [weak self] in + guard let self else { return } + Task { @MainActor in + self.applySearchState() + self.observeSearchState() + } + } + } + + func applyEditingMode() { + let isScriptEditing = viewModel.editingMode == .script + + dimOverlayView.isHidden = viewModel.editingMode != .title + segmentedControl.isHidden = isScriptEditing + playerView.isHidden = isScriptEditing || viewModel.searchMode + + pageTopToSegmentBottom.isActive = !isScriptEditing + pageTopToSafeArea.isActive = isScriptEditing + + if !viewModel.searchMode { + contentBottomToPlayerTop.isActive = !isScriptEditing + contentBottomToViewBottom.isActive = isScriptEditing + } + + navigationBar.apply( + to: navigationItem, + title: viewModel.title, + editingMode: viewModel.editingMode, + searchMode: viewModel.searchMode, + hasScriptEdits: viewModel.hasScriptEdits, + isTrashMode: viewModel.isTrashMode + ) + } + + func applySearchState() { + let isSearching = viewModel.searchMode + + segmentedControl.setCount(viewModel.summaryMatchCount, at: Page.summary.rawValue) + segmentedControl.setCount(viewModel.scriptMatchCount, at: Page.script.rawValue) + + matchAccessoryBar.configure( + countText: viewModel.matchCountText, + hasMatches: viewModel.hasCurrentPageMatches + ) + + let didToggle = navigationBar.apply( + to: navigationItem, + title: viewModel.title, + editingMode: viewModel.editingMode, + searchMode: isSearching, + hasScriptEdits: viewModel.hasScriptEdits, + isTrashMode: viewModel.isTrashMode + ) + + if didToggle { + let isScriptEditing = viewModel.editingMode == .script + playerView.isHidden = isSearching || isScriptEditing + matchAccessoryBar.isHidden = !isSearching + contentBottomToPlayerTop.isActive = !isSearching && !isScriptEditing + contentBottomToViewBottom.isActive = isSearching || isScriptEditing + } + + if let match = viewModel.currentMatch { + switch match.location { + case .keyPoint, .keyword: + summaryViewController.scrollToMatch(match) + case .script: + scriptViewController.scrollToMatch(match) + } + } + } + + func applyCurrentPage(_ page: Page) { + segmentedControl.selectSegment(index: page.rawValue) + + let target = pages[page.rawValue] + if let current = pageViewController.viewControllers?.first, + let currentIndex = pages.firstIndex(of: current), + current !== target + { + let direction: UIPageViewController.NavigationDirection = page.rawValue > currentIndex ? .forward : .reverse + pageViewController.setViewControllers([target], direction: direction, animated: true) + } + + if viewModel.searchMode { + applySearchState() + } + } +} + +// MARK: - UIPageViewControllerDataSource / Delegate + +extension VoiceNoteViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate { + public func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { + guard viewModel.editingMode == nil, + let idx = pages.firstIndex(of: viewController), idx > 0 else { return nil } + return pages[idx - 1] + } + + public func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController + ) -> UIViewController? { + guard viewModel.editingMode == nil, + let idx = pages.firstIndex(of: viewController), idx < pages.count - 1 else { return nil } + return pages[idx + 1] + } + + public func pageViewController( + _ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + guard completed, + let current = pageViewController.viewControllers?.first, + let index = pages.firstIndex(of: current), + let page = Page(rawValue: index) + else { return } + viewModel.updateCurrentPage(page) + } +} + +#if DEBUG + #Preview("보이스 노트") { + UINavigationController( + rootViewController: VoiceNoteViewController(viewModel: .preview()) + ) + } +#endif diff --git a/Presentation/Sources/ViewModel/Alert/ChaGokAlertViewModel.swift b/Presentation/Sources/ViewModel/Alert/ChaGokAlertViewModel.swift new file mode 100644 index 00000000..aedc0de6 --- /dev/null +++ b/Presentation/Sources/ViewModel/Alert/ChaGokAlertViewModel.swift @@ -0,0 +1,305 @@ +import Domain +import Foundation + +@MainActor +public protocol ChaGokAlertCoordinatorDelegate: AnyObject { + /// Open Alert + func presentAlert( + environment: ChaGokAlertViewModel.AlertEnvironment, + delegate: ChaGokAlertButtonTappedDelegate? + ) +} + +public extension ChaGokAlertCoordinatorDelegate { + func presentAlert(environment: ChaGokAlertViewModel.AlertEnvironment) { + presentAlert(environment: environment, delegate: nil) + } +} + +@MainActor +@objc +public protocol ChaGokAlertButtonTappedDelegate: AnyObject { + /// mic Permission Action + @objc + optional func micPermissionCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func micPermissionPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + /// recordingCancel Action + @objc + optional func recordingCancelCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func recordingCancelPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + + /// recordingComplete Action + @objc + optional func recordingCompleteCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func recordingCompletePrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + + /// languageSelect Action + @objc + optional func languageSelectCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func languageSelectPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + + /// createFolder Action + @objc + optional func createFolderCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func createFolderPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + + /// updateFolder Action + @objc + optional func updateFolderCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func updateFolderPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + + /// moveTrash Action + @objc + optional func moveTrashCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func moveTrashPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + + /// Trash Delete Action + @objc + optional func deleteAllTrashCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func deleteAllTrashPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func deleteItemsTrashCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func deleteItemsTrashPrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) + + /// none Action + @objc + optional func noneCloseButtonTapped(_ alertVC: ChaGokAlertViewController) + @objc + optional func nonePrimaryButtonTapped(_ alertVC: ChaGokAlertViewController) +} + +@MainActor +@Observable +public final class ChaGokAlertViewModel { + private(set) var environment: AlertEnvironment + private(set) var state: AlertState + private(set) var selectedLanguage: Language? + + public init(environment: AlertEnvironment = .none) { + self.environment = environment + state = environment.state + } + + public convenience init(state: AlertState) { + self.init(environment: .none) + self.state = state + } + + @ObservationIgnored + public var header: Header { + state.header + } + + @ObservationIgnored + public var style: BodyStyle { + state.bodyStyle + } + + public weak var coordinator: ChaGokAlertCoordinatorDelegate? +} + +// MARK: - Action + +extension ChaGokAlertViewModel { + public func update(environment: AlertEnvironment) { + self.environment = environment + state = environment.state + } + + func didTapCancel(delegate: ChaGokAlertButtonTappedDelegate?, alertVC: ChaGokAlertViewController) { + switch environment { + case .micPermissionRequired: + delegate?.micPermissionCloseButtonTapped?(alertVC) + case .recordingCancel: + delegate?.recordingCancelCloseButtonTapped?(alertVC) + case .recordingComplete: + delegate?.recordingCompleteCloseButtonTapped?(alertVC) + case .createFolder: + delegate?.createFolderCloseButtonTapped?(alertVC) + case .updateFolder: + delegate?.updateFolderCloseButtonTapped?(alertVC) + case .moveTrash: + delegate?.moveTrashCloseButtonTapped?(alertVC) + case .deleteAllTrash: + delegate?.deleteAllTrashCloseButtonTapped?(alertVC) + case .deleteItemsTrash: + delegate?.deleteItemsTrashCloseButtonTapped?(alertVC) + case .none: + delegate?.noneCloseButtonTapped?(alertVC) + } + } + + func didTapPrimary(delegate: ChaGokAlertButtonTappedDelegate?, alertVC: ChaGokAlertViewController) { + switch environment { + case .micPermissionRequired: + delegate?.micPermissionPrimaryButtonTapped?(alertVC) + case .recordingCancel: + delegate?.recordingCancelPrimaryButtonTapped?(alertVC) + case .recordingComplete: + delegate?.recordingCompletePrimaryButtonTapped?(alertVC) + case .createFolder: + delegate?.createFolderPrimaryButtonTapped?(alertVC) + case .updateFolder: + delegate?.updateFolderPrimaryButtonTapped?(alertVC) + case .moveTrash: + delegate?.moveTrashPrimaryButtonTapped?(alertVC) + case .deleteAllTrash: + delegate?.deleteAllTrashPrimaryButtonTapped?(alertVC) + case .deleteItemsTrash: + delegate?.deleteItemsTrashPrimaryButtonTapped?(alertVC) + case .none: + delegate?.nonePrimaryButtonTapped?(alertVC) + } + } + + func setSelectedLanguage(_ language: Language) { + selectedLanguage = language + } +} + +// MARK: - Alert Model + +public extension ChaGokAlertViewModel { + struct Header: Equatable { + let title: String + + init(title: String) { + self.title = title + } + } + + enum BodyStyle { + case basic(subTitle: String) + case textField(field: TextFieldView.Field, subTitle: String) + } + + enum ButtonType { + case close + case `default` + case primary + case danger + } + + struct ButtonStyle { + let type: ButtonType + let text: String + } + + struct AlertState { + let header: Header + let bodyStyle: BodyStyle + let cancelButtonStyle: ButtonStyle + let primaryButtonStyle: ButtonStyle + + init(header: Header, bodyStyle: BodyStyle, cancelButtonStyle: ButtonStyle, primaryButtonStyle: ButtonStyle) { + self.header = header + self.bodyStyle = bodyStyle + self.cancelButtonStyle = cancelButtonStyle + self.primaryButtonStyle = primaryButtonStyle + } + } + + @MainActor + enum AlertEnvironment { + case micPermissionRequired + case recordingCancel + case recordingComplete + case createFolder(TextFieldView.Field) + case updateFolder(TextFieldView.Field) + case moveTrash + case deleteAllTrash + case deleteItemsTrash + case none + + var state: AlertState { + switch self { + case .micPermissionRequired: + AlertState( + header: .init(title: "마이크 권한이 필요해요"), + bodyStyle: .basic(subTitle: "설정에서 마이크 권한을\n허용해주세요."), + cancelButtonStyle: .init(type: .close, text: "나중에"), + primaryButtonStyle: .init(type: .primary, text: "설정으로 이동") + ) + case .recordingCancel: + AlertState( + header: .init( + title: "녹음을 취소할까요?" + ), + bodyStyle: .basic(subTitle: "지금까지 녹음한 내용은\n저장되지 않아요."), + cancelButtonStyle: .init(type: .close, text: "계속 녹음"), + primaryButtonStyle: .init(type: .danger, text: "녹음 취소") + ) + case .recordingComplete: + AlertState( + header: .init( + title: "녹음을 저장하고 종료할까요?" + ), + bodyStyle: .basic(subTitle: "지금까지 녹음한 내용이\n기록됩니다."), + cancelButtonStyle: .init(type: .close, text: "아니오"), + primaryButtonStyle: .init(type: .primary, text: "저장 후 종료") + ) + case .createFolder(let field): + AlertState( + header: .init( + title: "새 폴더" + ), + bodyStyle: .textField(field: field, subTitle: "새로 만들 폴더의 이름을\n입력해주세요."), + cancelButtonStyle: .init(type: .close, text: "취소"), + primaryButtonStyle: .init(type: .primary, text: "만들기") + ) + case .updateFolder(let field): + AlertState( + header: .init( + title: "폴더 이름 수정" + ), + bodyStyle: .textField(field: field, subTitle: "수정 할 폴더의 이름을 입력해주세요"), + cancelButtonStyle: .init(type: .close, text: "취소"), + primaryButtonStyle: .init(type: .primary, text: "수정하기") + ) + case .moveTrash: + AlertState( + header: .init( + title: "기록을 삭제할까요?" + ), + bodyStyle: .basic(subTitle: "휴지통으로 이동되며,\n직접 비우기 전까지 보관돼요."), + cancelButtonStyle: .init(type: .close, text: "취소"), + primaryButtonStyle: .init(type: .danger, text: "삭제") + ) + case .deleteAllTrash: + AlertState( + header: .init( + title: "휴지통을 비울까요?" + ), + bodyStyle: .basic(subTitle: "모든 파일이 영구 삭제되며\n되돌릴 수 없어요"), + cancelButtonStyle: .init(type: .close, text: "취소"), + primaryButtonStyle: .init(type: .danger, text: "비우기") + ) + case .deleteItemsTrash: + AlertState( + header: .init( + title: "선택한 항목을 삭제할까요?" + ), + bodyStyle: .basic(subTitle: "선택한 항목이 영구 삭제되며\n되돌릴 수 없어요"), + cancelButtonStyle: .init(type: .close, text: "취소"), + primaryButtonStyle: .init(type: .danger, text: "삭제하기") + ) + case .none: + AlertState( + header: .init(title: ""), + bodyStyle: .basic(subTitle: ""), + cancelButtonStyle: .init(type: .close, text: "취소"), + primaryButtonStyle: .init(type: .primary, text: "저장하기") + ) + } + } + } +} diff --git a/Presentation/Sources/ViewModel/CoordinatorDelegate/MainCoordinatorDelegate.swift b/Presentation/Sources/ViewModel/CoordinatorDelegate/MainCoordinatorDelegate.swift new file mode 100644 index 00000000..94a368d2 --- /dev/null +++ b/Presentation/Sources/ViewModel/CoordinatorDelegate/MainCoordinatorDelegate.swift @@ -0,0 +1,22 @@ +import Domain +import Foundation + +// MARK: - Coordinator Delegate 패턴 + +@MainActor +public protocol MainCoordinatorDelegate: AnyObject { + /// 휴지통으로 push하는 함수 + func pushTrashView() + /// 개인 폴더로 push 하는 함수 + func pushMyFolderView(category: CategoryToggle) + /// 음성 노트로 push 하는 함수 + func pushVoiceNoteView(voiceNote: VoiceNote, isTrashMode: Bool) + /// 녹음 시작 present 함수 + func presentRecodingView() + /// 공용 Pop함수 + func pop() + /// 검색 화면 Push함수 + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem]) + /// 설정 화면 push + func pushSettingView() +} diff --git a/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift b/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift new file mode 100644 index 00000000..421f1149 --- /dev/null +++ b/Presentation/Sources/ViewModel/Folder/FolderDetailViewModel.swift @@ -0,0 +1,344 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol FolderDetailCoordinatorDelegate: AnyObject { + /// 뒤로 가기 + func pop() + /// 음성 노트 가기 + func pushVoiceNoteView(voiceNote: VoiceNote, isTrashMode: Bool) + /// 폴더 이동 Sheet + func presentFolderList(with voiceNotes: [VoiceNote], onComplete: ((String) -> Void)?) + /// 검색 화면 Push함수 + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem]) +} + +@MainActor +@Observable +public final class FolderDetailViewModel { + // MARK: - State + + enum Order { + case createdAt + case updatedAt + } + + let title: String + let folderID: UUID + private(set) var items: [ContentItem] = [] + private(set) var errorMessage: String? + private(set) var order: Order = .createdAt + private(set) var select: SelectionMode = .none + private(set) var selectedItems: [VoiceNote] = [] + public private(set) var isTrashMode: Bool = false + @ObservationIgnored + private var observationTask: Task? + + // MARK: - 화면 전환 + + public weak var coordinator: FolderDetailCoordinatorDelegate? + public weak var alertCoordinator: ChaGokAlertCoordinatorDelegate? + + // MARK: - UseCase + + private let voiceNoteUseCase: any VoiceNoteUseCase + + // MARK: - Initialize + + public init( + title: String, + folderID: UUID, + isTrashMode: Bool = false, + voiceNoteUseCase: any VoiceNoteUseCase + ) { + self.title = title + self.folderID = folderID + self.isTrashMode = isTrashMode + self.voiceNoteUseCase = voiceNoteUseCase + sortItems() + } +} + +// MARK: - Setter / Getter + +extension FolderDetailViewModel { + func setOrder(_ order: Order) { + self.order = order + sortItems() + } + + func setSelectionMode(_ select: SelectionMode) { + self.select = select + if select == .none { + allClearSelected() + } else if select == .all { + allSelected() + } + } + + func selectItem(_ item: VoiceNote) { + if select == .none { setSelectionMode(.multiple) } + selectedItems.append(item) + } + + func deselectItem(_ item: VoiceNote) { + selectedItems.removeAll { $0.id == item.id } + } +} + +// MARK: Action + +extension FolderDetailViewModel { + /// 뒤로가기 + func didTapBack() { + coordinator?.pop() + } + + /// 음성 노트 화면 전환 + func pushVoiceNote(voiceNote: VoiceNote) { + coordinator?.pushVoiceNoteView(voiceNote: voiceNote, isTrashMode: isTrashMode) + } + + /// 폴더 이동 Present + func presentMoveFolder(dismiss: @escaping (String) -> Void) { + guard !selectedItems.isEmpty else { return } + coordinator?.presentFolderList(with: selectedItems, onComplete: dismiss) + } + + /// 검색화면 이동 + func pushSearch() { + coordinator?.pushSearchView(type: .myDetailFolder(title), items: items) + } + + /// 전체 선택 + private func allSelected() { + selectedItems = items.compactMap { + if case .voiceNote(let voiceNote) = $0 { return voiceNote } + return nil + } + } + + /// 전체 선택 해제 + private func allClearSelected() { + selectedItems = [] + } + + /// 삭제 버튼 Tapped + func deleteButtonTapped(alertAction: () -> Void) { + guard !selectedItems.isEmpty else { + setSelectionMode(.none) + return + } + alertAction() + } +} + +// MARK: - Lifecycle + +extension FolderDetailViewModel { + func onAppear() { + guard observationTask == nil else { return } + observationTask = Task { [weak self] in + guard let self else { return } + do { + let stream = try voiceNoteUseCase.observe(folderID: folderID) + for await voiceNotes in stream { + items = voiceNotes.map { .voiceNote($0) } + sortItems() + } + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + } + + func onDisappear() { + observationTask?.cancel() + observationTask = nil + } + + private func sortItems() { + switch order { + case .createdAt: + items.sort { + switch ($0, $1) { + case (.voiceNote(let l), .voiceNote(let r)): + return l.createdAt > r.createdAt + default: + return false + } + } + case .updatedAt: + items.sort { + switch ($0, $1) { + case (.voiceNote(let l), .voiceNote(let r)): + return l.updatedAt > r.updatedAt + default: + return false + } + } + } + } +} + +// MARK: - Move ( delete ) + +extension FolderDetailViewModel { + func move() { + guard !selectedItems.isEmpty else { + setSelectionMode(.none) + return + } + do { + for note in selectedItems { + try voiceNoteUseCase.moveToTrash(noteID: note.id) + } + let selectedIDs = Set(selectedItems.map(\.id)) + items.removeAll { item in + if case .voiceNote(let v) = item { return selectedIDs.contains(v.id) } + return false + } + setSelectionMode(.none) + } catch { + AppLogger.error(error) + errorMessage = error.errorDescription + } + } + + func move(id: VoiceNote.ID) { + do { + try voiceNoteUseCase.moveToTrash(noteID: id) + items.removeAll { + if case .voiceNote(let obj) = $0 { return obj.id == id } + return false + } + } catch { + AppLogger.error(error) + } + } +} + +// MARK: - Restore (휴지통 이동 복구) + +extension FolderDetailViewModel { + func restore(items: [VoiceNote]) { + for item in items { + do { + try voiceNoteUseCase.restore(noteID: item.id) + } catch { + AppLogger.error(error) + errorMessage = error.errorDescription + } + } + } +} + +#if DEBUG + extension FolderDetailViewModel { + static func preview( + title: String = "폴더 상세", + folderID: UUID = UUID() + ) -> FolderDetailViewModel { + let previewData = PreviewData.make(folderID: folderID) + + return FolderDetailViewModel( + title: title, + folderID: folderID, + voiceNoteUseCase: PreviewVoiceNoteUseCase(items: previewData.items) + ) + } + } + + private extension FolderDetailViewModel { + struct PreviewData { + let items: [VoiceNote] + + static func make(folderID: UUID, now: Date = .now) -> Self { + let items: [VoiceNote] = (0 ..< 12).map { index in + let createdOffset = TimeInterval((index + 1) * 3600) * -1 + let updatedOffset = TimeInterval((index + 1) * 1800) * -1 + return VoiceNote( + title: "폴더 상세 메모 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + updatedAt: now.addingTimeInterval(updatedOffset), + folderID: folderID, + voiceRecord: VoiceRecord( + audioFilePath: "preview-\(index + 1).m4a", + duration: Double(90 + (index * 15)) + ), + analysisState: .pending + ) + } + return PreviewData(items: items) + } + } + + struct PreviewVoiceNoteUseCase: VoiceNoteUseCase { + let items: [VoiceNote] + + func create(_ voiceRecord: VoiceRecord) throws(VoiceNoteUseCaseError) -> VoiceNote { + VoiceNote( + title: "미리보기 기록", + createdAt: .now, + updatedAt: .now, + folderID: UUID(), + voiceRecord: voiceRecord, + keywords: [], + transcript: nil, + summary: nil, + analysisState: .pending + ) + } + + func fetch(byId id: UUID) throws(VoiceNoteUseCaseError) -> VoiceNote { + guard let item = items.first(where: { $0.id == id }) else { + throw .recordNotFound(id) + } + return item + } + + func update(_ voiceNote: VoiceNote) throws(VoiceNoteUseCaseError) -> VoiceNote { + voiceNote + } + + func observe(id: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream { + guard let item = items.first(where: { $0.id == id }) else { + throw .recordNotFound(id) + } + return AsyncStream { continuation in + continuation.yield(item) + continuation.finish() + } + } + + func observe(folderID: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let filtered = items.filter { $0.folderID == folderID } + return AsyncStream { continuation in + continuation.yield(filtered) + continuation.finish() + } + } + + func observeRecent(limit: Int) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let recent = Array(items.prefix(limit)) + return AsyncStream { continuation in + continuation.yield(recent) + continuation.finish() + } + } + + func observeTrashed() throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + AsyncStream { $0.finish() } + } + + func regenerateSummary(id _: UUID) {} + + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + } + } + +#endif diff --git a/Presentation/Sources/ViewModel/Folder/FolderViewModel.swift b/Presentation/Sources/ViewModel/Folder/FolderViewModel.swift new file mode 100644 index 00000000..31f3397d --- /dev/null +++ b/Presentation/Sources/ViewModel/Folder/FolderViewModel.swift @@ -0,0 +1,249 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol FolderCoordinatorDelegate: AnyObject { + /// 뒤로 가기 + func pop() + /// 폴더 상세 Push + func pushMyFolderDetailView(_ folder: Folder) + /// 검색 화면 Push함수 + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem]) +} + +@MainActor +@Observable +public final class FolderViewModel { + // MARK: - State + + var category: CategoryToggle + private(set) var editFolder: Folder? + private(set) var mode: TextFieldView.Mode = .create + private(set) var errorMessage: String? + public weak var coordinator: FolderCoordinatorDelegate? + public weak var alertCoordinator: ChaGokAlertCoordinatorDelegate? + public var showFolderAlert: ((TextFieldView.Field) -> Void)? + + // MARK: - Dependencies + + private let folderUseCase: any FolderUseCase + + // MARK: - Initialize + + public init( + category: CategoryToggle, + folderUseCase: any FolderUseCase + ) { + self.category = category + self.folderUseCase = folderUseCase + } +} + +// MARK: - Setter / Getter + +extension FolderViewModel { + private func setMode(_ mode: TextFieldView.Mode) { + self.mode = mode + } + + func openTextField(for folder: Folder? = nil) { + errorMessage = nil + editFolder = folder + let currentMode: TextFieldView.Mode = folder == nil ? .create : .edit + setMode(currentMode) + let field = TextFieldView.Field( + mode: currentMode, + title: "", + subTitle: "", + placeHolder: "폴더 이름을 적어주세요", + text: folder?.name ?? "", + errorMessage: nil + ) + showFolderAlert?(field) + } + + func closeTextField() { + errorMessage = nil + editFolder = nil + setMode(.create) + } +} + +// MARK: - Action + +extension FolderViewModel { + func didTapBack() { + coordinator?.pop() + } + + func pushDetail(_ folder: Folder) { + coordinator?.pushMyFolderDetailView(folder) + } + + func pushSearch() { + coordinator?.pushSearchView(type: .myFolder, items: category.items) + } +} + +// MARK: - C R U D + +extension FolderViewModel { + /// Domain.Folder를 생성하는 함수 + func create(name: String) { + do { + let folder = try folderUseCase.create(name: name) + category.items.insert(.folder(folder), at: 0) + closeTextField() + } catch { + AppLogger.error(error) + errorMessage = error.errorDescription + } + } + + func fetchAll() { + do { + let folders: [Folder] = try folderUseCase.fetchDeletableFolders() + let items: [ContentItem] = folders.map { .folder($0) } + category.items = items + } catch { + AppLogger.error(error) + } + } + + func update(name: String) { + guard let folder = editFolder else { return } + + let updatedFolder = Folder( + id: folder.id, + name: name, + createdAt: folder.createdAt, + voiceNoteIDs: folder.voiceNoteIDs, + kind: folder.kind, + deletedAt: folder.deletedAt + ) + + do { + let updated = try folderUseCase.update(updatedFolder) + if let index = category.items.firstIndex(where: { + if case .folder(let folder) = $0 { + return folder.id == updated.id + } + return false + }) { + category.items[index] = .folder(updated) + } + closeTextField() + } catch { + AppLogger.error(error) + errorMessage = error.errorDescription + } + } + + func move(folder: Folder) { + do { + try folderUseCase.moveToTrash(folderID: folder.id) + category.items.removeAll { + if case .folder(let obj) = $0 { return obj.id == folder.id } + return false + } + } catch { + AppLogger.error(error) + } + } +} + +#if DEBUG + extension FolderViewModel { + static func preview() -> FolderViewModel { + let previewData = PreviewData.make() + let category = CategoryToggle( + imageName: "folder", + title: "개인 폴더", + items: previewData.folders.map(ContentItem.folder) + ) + + return FolderViewModel( + category: category, + folderUseCase: PreviewFolderUseCase(items: previewData.folders) + ) + } + } + + private extension FolderViewModel { + struct PreviewData { + let folders: [Folder] + + static func make(now: Date = .now) -> Self { + let folders: [Folder] = (0 ..< 10).map { index in + let createdOffset = TimeInterval((index + 1) * 86400) * -1 + return Folder( + name: "개인 폴더 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + kind: .custom + ) + } + return PreviewData(folders: folders) + } + } + + struct PreviewFolderUseCase: FolderUseCase { + let items: [Folder] + + func create(name: String) throws(FolderUseCaseError) -> Folder { + Folder(name: name, createdAt: .now, kind: .custom) + } + + func createDefault() throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func createTrash() throws(FolderUseCaseError) -> Folder { + Folder(name: "휴지통", kind: .trash) + } + + func fetchAll() throws(FolderUseCaseError) -> [Folder] { + items + } + + func fetchDefault() throws(FolderUseCaseError) -> Folder { + guard let folder = items.first(where: { $0.kind == .default }) else { throw .notFound } + return folder + } + + func fetchTrash() throws(FolderUseCaseError) -> Folder { + guard let folder = items.first(where: { $0.kind == .trash }) else { throw .notFound } + return folder + } + + func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] { + items.filter { $0.kind == .custom } + } + + func fetch(by id: UUID) throws(FolderUseCaseError) -> Folder { + guard let item = items.first(where: { $0.id == id }) else { throw .notFound } + return item + } + + func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder { + folder + } + + func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + let snapshot = items.filter { $0.kind == .custom } + return AsyncStream { continuation in + continuation.yield(snapshot) + continuation.finish() + } + } + + func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + AsyncStream { $0.finish() } + } + + func moveToTrash(folderID _: UUID) throws(FolderUseCaseError) {} + func restore(folderID _: UUID) throws(FolderUseCaseError) {} + func delete(folderID _: UUID) throws(FolderUseCaseError) {} + } + } +#endif diff --git a/Presentation/Sources/ViewModel/Main/MainDataType.swift b/Presentation/Sources/ViewModel/Main/MainDataType.swift new file mode 100644 index 00000000..dad06554 --- /dev/null +++ b/Presentation/Sources/ViewModel/Main/MainDataType.swift @@ -0,0 +1,50 @@ +import Domain +import Foundation +import Observation + +public enum MainSection: Hashable, Sendable { + case list + case groupedList(MainListDateGroup) + case emptyList +} + +public enum MainListDateGroup: Int, Hashable, Sendable, CaseIterable { + case today + case recentSevenDays + case older + + var title: String { + switch self { + case .today: return "오늘" + case .recentSevenDays: return "최근 7일" + case .older: return "이전" + } + } +} + +public struct CategoryToggle: Hashable, Sendable { + public let id: UUID = UUID() + public let imageName: String + public let title: String + public var items: [ContentItem] + + public init(imageName: String, title: String, items: [ContentItem]) { + self.imageName = imageName + self.title = title + self.items = items + } + + public static func == (lhs: CategoryToggle, rhs: CategoryToggle) -> Bool { + lhs.id == rhs.id && lhs.items == rhs.items + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(items) + } +} + +public enum MainCellItem: Hashable, Sendable { + case list(ContentItem) + case emptyList +} diff --git a/Presentation/Sources/ViewModel/Main/MainViewModel.swift b/Presentation/Sources/ViewModel/Main/MainViewModel.swift new file mode 100644 index 00000000..4243ba01 --- /dev/null +++ b/Presentation/Sources/ViewModel/Main/MainViewModel.swift @@ -0,0 +1,586 @@ +import Core +import Domain +import Foundation + +@MainActor +@Observable +public final class MainViewModel { + // MARK: - State + + /// 통합된 카테고리 데이터 + private(set) var categoryData: [CategoryToggle] = [ + CategoryToggle( + imageName: "clock", + title: "최근 기록", + items: [] + ), + CategoryToggle( + imageName: "microphone", + title: "모든 기록", + items: [] + ), + CategoryToggle( + imageName: "folder", + title: "폴더 목록", + items: [] + ), + CategoryToggle( + imageName: "trash", + title: "휴지통", + items: [] + ) + ] + + @ObservationIgnored + private(set) var selectedCategoryIndex: Int = 0 + @ObservationIgnored + private(set) var didScroll: Bool = false + + var shouldGroupSelectedCategory: Bool { + selectedCategoryIndex == 1 + } + + var isEmptyList: Bool { + categoryData[selectedCategoryIndex].items.isEmpty + } + + private(set) var errorMessage: String? + + // MARK: - UseCase + + let microphoneRepository: VoiceRecordRepository + let voiceNoteUseCase: any VoiceNoteUseCase + let folderUseCase: any FolderUseCase + + @ObservationIgnored + private var recentTask: Task? + @ObservationIgnored + private var voiceNoteTask: Task? + @ObservationIgnored + private var myFolderTask: Task? + @ObservationIgnored + private var trashFoldersTask: Task? + @ObservationIgnored + private var trashNotesTask: Task? + + @ObservationIgnored + private var trashedFolders: [Folder] = [] + @ObservationIgnored + private var trashedNotes: [VoiceNote] = [] + + // TODO: 화면 전환 + public weak var mainCoordinator: MainCoordinatorDelegate? + public weak var alertCoordinator: ChaGokAlertCoordinatorDelegate? + + public init( + microphoneRepository: any VoiceRecordRepository, + voiceNoteUseCase: any VoiceNoteUseCase, + folderUseCase: any FolderUseCase + ) { + self.microphoneRepository = microphoneRepository + self.voiceNoteUseCase = voiceNoteUseCase + self.folderUseCase = folderUseCase + } +} + +// MARK: - Getter / Setter + +extension MainViewModel { + func setSelectedCategoryIndex(indexPath: IndexPath) { + selectedCategoryIndex = indexPath.item + + // 휴지통 (index 3) 선택 시 화면 전환 트리거 + if selectedCategoryIndex == categoryData.count - 1 { + pushTrashView() + // 화면 이동 후 index를 되돌린다. + selectedCategoryIndex = 0 + } + + if selectedCategoryIndex == categoryData.count - 2 { + pushMyFolderView() + // 화면 이동 후 index를 되돌린다. + selectedCategoryIndex = 0 + } + } + + func setDidScroll(_ didScroll: Bool) { + self.didScroll = didScroll + } +} + +// MARK: - Helper Function + +extension MainViewModel { + func pushTrashView() { + mainCoordinator?.pushTrashView() + } + + func pushMyFolderView() { + mainCoordinator?.pushMyFolderView(category: categoryData[2]) + } + + func pushVoiceNoteView(voiceNote: VoiceNote) { + mainCoordinator?.pushVoiceNoteView(voiceNote: voiceNote, isTrashMode: false) + } + + func presentRecodingView() { + mainCoordinator?.presentRecodingView() + } + + func pushSearchView() { + var uniqueItems: [ContentItem] = [] + var seenIDs: Set = [] + + let searchableCategories = categoryData.prefix(3) + + for item in searchableCategories.flatMap(\.items) { + if !seenIDs.contains(item.id) { + seenIDs.insert(item.id) + uniqueItems.append(item) + } + } + + mainCoordinator?.pushSearchView( + type: .main, + items: uniqueItems + ) + } + + func pushSettingView() { + mainCoordinator?.pushSettingView() + } +} + +// MARK: - Update CategoryData + +extension MainViewModel { + /// 최근 기록(전체 폴더 최신 5개) 관찰 시작 + func updateRecentCategory() { + guard recentTask == nil else { return } + recentTask = Task { [weak self] in + guard let self else { return } + do { + let stream = try voiceNoteUseCase.observeRecent(limit: Policy.recentVoiceNoteLimit) + for await voiceNotes in stream { + categoryData[0].items = voiceNotes.map { .voiceNote($0) } + } + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + } + + /// 기본 폴더(음성 노트) 관찰 시작 + func updateVoiceNoteCategory() { + guard voiceNoteTask == nil else { return } + voiceNoteTask = Task { [weak self] in + guard let self else { return } + do { + let defaultFolder = try folderUseCase.fetchDefault() + let stream = try voiceNoteUseCase.observe(folderID: defaultFolder.id) + for await voiceNotes in stream { + categoryData[1].items = voiceNotes.map { .voiceNote($0) } + } + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + } + + /// 개인 폴더 관찰 시작 + func updateMyFolderCategory() { + guard myFolderTask == nil else { return } + myFolderTask = Task { [weak self] in + guard let self else { return } + do { + let stream = try folderUseCase.observeCustom() + for await folders in stream { + categoryData[2].items = folders.map { ContentItem.folder($0) } + } + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + } + + /// 휴지통 관찰 시작 — 삭제된 폴더 + 단독 trashed 노트 stream을 합쳐 emit + func updateTrashCategory() { + guard trashFoldersTask == nil, trashNotesTask == nil else { return } + do { + let foldersStream = try folderUseCase.observeTrashed() + let notesStream = try voiceNoteUseCase.observeTrashed() + trashFoldersTask = Task { [weak self] in + for await folders in foldersStream { + self?.applyTrashedFolders(folders) + } + } + trashNotesTask = Task { [weak self] in + for await notes in notesStream { + self?.applyTrashedNotes(notes) + } + } + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + private func applyTrashedFolders(_ folders: [Folder]) { + trashedFolders = folders + refreshTrashCategory() + } + + private func applyTrashedNotes(_ notes: [VoiceNote]) { + trashedNotes = notes + refreshTrashCategory() + } + + private func refreshTrashCategory() { + categoryData[3].items = trashedFolders.map(ContentItem.folder) + trashedNotes.map(ContentItem.voiceNote) + } + + /// 관찰 중인 모든 카테고리 stream을 취소합니다. + func cancelObservations() { + recentTask?.cancel() + recentTask = nil + voiceNoteTask?.cancel() + voiceNoteTask = nil + myFolderTask?.cancel() + myFolderTask = nil + trashFoldersTask?.cancel() + trashFoldersTask = nil + trashNotesTask?.cancel() + trashNotesTask = nil + } +} + +// MARK: - Mic Permission + +extension MainViewModel { + func handleRecordButtonTap(alertAction: () -> Void) { + let status = microphoneRepository.checkMicrophonePermission() + if status != .authorized { + alertAction() + } else { + presentRecodingView() + } + } +} + +#if DEBUG + extension MainViewModel { + static func preview(selectedCategoryIndex: Int = 0) -> MainViewModel { + let previewData = PreviewData.make() + let viewModel = MainViewModel( + microphoneRepository: PreviewMicrophoneRepository(), + voiceNoteUseCase: PreviewVoiceNoteUseCase( + recentItems: previewData.recentVoiceNotes, + defaultItems: previewData.defaultVoiceNotes, + trashedItems: previewData.trashedNotes + ), + folderUseCase: PreviewFolderUseCase( + items: previewData.folders, + trashedItems: previewData.trashedFolders + ) + ) + + viewModel.categoryData[0].items = previewData.recentVoiceNotes.map(ContentItem.voiceNote) + viewModel.categoryData[1].items = previewData.defaultVoiceNotes.map(ContentItem.voiceNote) + viewModel.categoryData[2].items = previewData.folders.map(ContentItem.folder) + viewModel.categoryData[3].items = previewData.trashedFolders.map(ContentItem.folder) + + previewData.trashedNotes.map(ContentItem.voiceNote) + viewModel.selectedCategoryIndex = max(0, min(selectedCategoryIndex, viewModel.categoryData.count - 1)) + + return viewModel + } + } + + private extension MainViewModel { + struct PreviewData { + let recentVoiceNotes: [VoiceNote] + let defaultVoiceNotes: [VoiceNote] + let folders: [Folder] + let trashedFolders: [Folder] + let trashedNotes: [VoiceNote] + + static func make(now: Date = .now) -> Self { + let defaultFolderID = UUID() + let personalFolderID = UUID() + + let recentVoiceNotes: [VoiceNote] = (0 ..< 10).map { index in + let createdOffset = TimeInterval((index + 1) * 1800) * -1 + let updatedOffset = TimeInterval((index + 1) * 900) * -1 + let duration = Double(180 + index * 35) + + return Self.makeVoiceNote( + title: "최근 기록 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + updatedAt: now.addingTimeInterval(updatedOffset), + folderID: defaultFolderID, + duration: duration, + summarized: index.isMultiple(of: 2) + ) + } + + let defaultOffsets: [TimeInterval] = [ + -600, -3600, -21600, + -86400, -172_800, -259_200, -432_000, + -864_000, -1_209_600, -2_592_000 + ] + let defaultVoiceNotes: [VoiceNote] = defaultOffsets.enumerated().map { index, offset in + Self.makeVoiceNote( + title: "기본 폴더 메모 \(index + 1)", + createdAt: now.addingTimeInterval(offset), + updatedAt: now.addingTimeInterval(offset / 2), + folderID: defaultFolderID, + duration: Double(240 + index * 20), + summarized: index.isMultiple(of: 3) + ) + } + + let folders: [Folder] = (0 ..< 10).map { index in + let createdOffset = TimeInterval((index + 1) * 86400) * -1 + let prefixCount = (index % 4) + 1 + let noteIDs = Array(defaultVoiceNotes.prefix(prefixCount).map(\.id)) + return Folder( + name: "개인 폴더 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + voiceNoteIDs: noteIDs, + kind: .custom + ) + } + + var trashedFolders: [Folder] = [] + var trashedNotes: [VoiceNote] = [] + for index in 0 ..< 10 { + if index.isMultiple(of: 2) { + let createdOffset = TimeInterval((index + 2) * 43200) * -1 + let updatedOffset = TimeInterval((index + 1) * 21600) * -1 + trashedNotes.append( + Self.makeVoiceNote( + title: "휴지통 메모 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + updatedAt: now.addingTimeInterval(updatedOffset), + folderID: personalFolderID, + duration: Double(120 + index * 15), + summarized: false + ) + ) + } else { + let createdOffset = TimeInterval((index + 1) * 64800) * -1 + let deletedOffset = TimeInterval((index + 1) * 10800) * -1 + trashedFolders.append( + Folder( + name: "휴지통 폴더 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + kind: .custom, + deletedAt: now.addingTimeInterval(deletedOffset) + ) + ) + } + } + + return PreviewData( + recentVoiceNotes: recentVoiceNotes, + defaultVoiceNotes: defaultVoiceNotes, + folders: folders, + trashedFolders: trashedFolders, + trashedNotes: trashedNotes + ) + } + + static func makeVoiceNote( + title: String, + createdAt: Date, + updatedAt: Date, + folderID: UUID, + duration: Double, + summarized: Bool + ) -> VoiceNote { + let record = VoiceRecord( + createdAt: createdAt, + audioFilePath: "VoiceRecords/\(UUID().uuidString).m4a", + duration: duration + ) + + return VoiceNote( + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + folderID: folderID, + voiceRecord: record, + transcript: summarized + ? Transcript(sections: [TranscriptSection(timestamp: 0, text: "\(title) 전사본")]) + : nil, + summary: summarized ? Summary(text: "\(title) 요약") : nil, + analysisState: summarized ? .completed : .pending + ) + } + } + + struct PreviewMicrophoneRepository: VoiceRecordRepository { + func checkMicrophonePermission() -> PermissionStatus { + .denied + } + + func requestMicrophonePermission() async throws(Domain.VoiceRecordRepositoryError) -> Domain + .PermissionStatus + { + .authorized + } + + func startRecording() async throws(Domain.VoiceRecordRepositoryError) -> AsyncStream { + AsyncStream { continuation in + continuation.yield(Domain.Waveform(amplitudes: [0.12, 0.31, 0.45, 0.22, 0.38])) + continuation.yield(Domain.Waveform(amplitudes: [0.27, 0.51, 0.18, 0.34, 0.42])) + continuation.finish() + } + } + + func pauseRecording() async throws(Domain.VoiceRecordRepositoryError) { + // Preview mock: no-op + } + + func resumeRecording() async throws(Domain.VoiceRecordRepositoryError) { + // Preview mock: no-op + } + + func finishRecording() async throws(Domain.VoiceRecordRepositoryError) -> Domain.VoiceRecord { + Domain.VoiceRecord( + createdAt: .now, + audioFilePath: "VoiceRecords/preview.m4a", + duration: 95 + ) + } + + func cancelRecording() async throws(Domain.VoiceRecordRepositoryError) { + // Preview mock: no-op + } + } + + struct PreviewVoiceNoteUseCase: VoiceNoteUseCase { + let recentItems: [VoiceNote] + let defaultItems: [VoiceNote] + let trashedItems: [VoiceNote] + + func create(_ voiceRecord: VoiceRecord) throws(VoiceNoteUseCaseError) -> VoiceNote { + defaultItems[0] + } + + func fetch(byId id: UUID) throws(VoiceNoteUseCaseError) -> VoiceNote { + guard let item = defaultItems.first(where: { $0.id == id }) else { + throw .recordNotFound(id) + } + return item + } + + func update(_ voiceNote: VoiceNote) throws(VoiceNoteUseCaseError) -> VoiceNote { + voiceNote + } + + func observe(id: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream { + guard let item = defaultItems.first(where: { $0.id == id }) else { + throw .recordNotFound(id) + } + return AsyncStream { $0.yield(item) } + } + + func observe(folderID: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let filtered = defaultItems.filter { $0.folderID == folderID } + return AsyncStream { continuation in + continuation.yield(filtered) + continuation.finish() + } + } + + func observeRecent(limit: Int) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let recent = Array(recentItems.prefix(limit)) + return AsyncStream { continuation in + continuation.yield(recent) + continuation.finish() + } + } + + func observeTrashed() throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let snapshot = trashedItems + return AsyncStream { continuation in + continuation.yield(snapshot) + continuation.finish() + } + } + + func regenerateSummary(id _: UUID) {} + + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + } + + struct PreviewFolderUseCase: FolderUseCase { + let items: [Folder] + let trashedItems: [Folder] + + func create(name: String) throws(FolderUseCaseError) -> Folder { + items[0] + } + + func createDefault() throws(FolderUseCaseError) -> Folder { + items[0] + } + + func createTrash() throws(FolderUseCaseError) -> Folder { + items[0] + } + + func fetchAll() throws(FolderUseCaseError) -> [Folder] { + items + } + + func fetchDefault() throws(FolderUseCaseError) -> Folder { + guard let folder = items.first(where: { $0.kind == .default }) else { throw .notFound } + return folder + } + + func fetchTrash() throws(FolderUseCaseError) -> Folder { + guard let folder = items.first(where: { $0.kind == .trash }) else { throw .notFound } + return folder + } + + func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] { + items.filter { $0.kind == .custom } + } + + func fetch(by id: UUID) throws(FolderUseCaseError) -> Folder { + guard let item = items.first(where: { $0.id == id }) else { throw .notFound } + return item + } + + func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder { + folder + } + + func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + let snapshot = items.filter { $0.kind == .custom } + return AsyncStream { continuation in + continuation.yield(snapshot) + continuation.finish() + } + } + + func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + let snapshot = trashedItems + return AsyncStream { continuation in + continuation.yield(snapshot) + continuation.finish() + } + } + + func moveToTrash(folderID _: UUID) throws(FolderUseCaseError) {} + func restore(folderID _: UUID) throws(FolderUseCaseError) {} + func delete(folderID _: UUID) throws(FolderUseCaseError) {} + } + } +#endif diff --git a/Presentation/Sources/ViewModel/MoveVoiceNote/MoveFolderListViewModel.swift b/Presentation/Sources/ViewModel/MoveVoiceNote/MoveFolderListViewModel.swift new file mode 100644 index 00000000..6c6d08fd --- /dev/null +++ b/Presentation/Sources/ViewModel/MoveVoiceNote/MoveFolderListViewModel.swift @@ -0,0 +1,118 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol MoveFolderListCoordinatorDelegate: AnyObject { + func dismiss() + func pushNewFolder() +} + +@MainActor +@Observable +public final class MoveFolderListViewModel { + public weak var coordinator: MoveFolderListCoordinatorDelegate? + private(set) var state: State = .init() + + private let voiceNotes: [VoiceNote] + private let folderUseCase: any FolderUseCase + private let voiceNoteUseCase: any VoiceNoteUseCase + private let onComplete: ((String) -> Void)? + + public init( + voiceNotes: [VoiceNote], + folderUseCase: any FolderUseCase, + voiceNoteUseCase: any VoiceNoteUseCase, + onComplete: ((String) -> Void)? = nil + ) { + self.voiceNotes = voiceNotes + self.folderUseCase = folderUseCase + self.voiceNoteUseCase = voiceNoteUseCase + self.onComplete = onComplete + } + + func send(_ action: Action) { + switch action { + case .view(let viewAction): + switch viewAction { + case .onAppear: + fetchFolders() + case .folderSelected(let folder): + state.selectedFolder = folder + case .moveButtonTapped: + moveVoiceNote() + case .closeButtonTapped: + coordinator?.dismiss() + case .addFolderButtonTapped: + coordinator?.pushNewFolder() + case .errorMessageDismissed: + state.errorMessage = nil + } + case .internal(let internalAction): + switch internalAction { + case .foldersLoaded(let folders): + state.folders = folders + } + } + } + + private func fetchFolders() { + do { + guard let currentFolderID = voiceNotes.first?.folderID else { return } + let folders = try folderUseCase.fetchAll() + let otherFolders = folders.filter { $0.id != currentFolderID } + send(.internal(.foldersLoaded(otherFolders))) + } catch { + AppLogger.error(error) + state.errorMessage = error.localizedDescription + } + } + + private func moveVoiceNote() { + guard let selectedFolder = state.selectedFolder else { return } + do { + for var voiceNote in voiceNotes { + voiceNote.folderID = selectedFolder.id + _ = try voiceNoteUseCase.update(voiceNote) + } + onComplete?(selectedFolder.name) + coordinator?.dismiss() + } catch { + AppLogger.error(error) + state.errorMessage = error.localizedDescription + } + } +} + +extension MoveFolderListViewModel { + struct State { + let leftTitle = "이동할 폴더 선택" + let addFolderButtonTitle = "새 폴더" + let moveButtonTitle = "이동하기" + var selectedFolder: Folder? + var folders: [Folder] = [] + var errorMessage: String? + + var isMoveButtonEnabled: Bool { + selectedFolder != nil + } + } + + public enum Action { + public enum View { + case onAppear + case folderSelected(Folder) + case moveButtonTapped + case closeButtonTapped + case addFolderButtonTapped + case errorMessageDismissed + } + + public enum Internal { + case foldersLoaded([Folder]) + } + + case view(View) + case `internal`(Internal) + } +} diff --git a/Presentation/Sources/ViewModel/MoveVoiceNote/NewFolderViewModel.swift b/Presentation/Sources/ViewModel/MoveVoiceNote/NewFolderViewModel.swift new file mode 100644 index 00000000..7334e949 --- /dev/null +++ b/Presentation/Sources/ViewModel/MoveVoiceNote/NewFolderViewModel.swift @@ -0,0 +1,63 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol NewFolderCoordinatorDelegate: AnyObject { + func cancel() + func folderCreated() +} + +@MainActor +@Observable +public final class NewFolderViewModel { + public weak var coordinator: NewFolderCoordinatorDelegate? + private(set) var state: State = .init() + + private let folderUseCase: any FolderUseCase + + public init(folderUseCase: any FolderUseCase) { + self.folderUseCase = folderUseCase + } + + func send(_ action: Action) { + switch action { + case .view(let viewAction): + switch viewAction { + case .cancelButtonTapped: + coordinator?.cancel() + case .createButtonTapped(let name): + createFolder(name: name) + } + } + } + + func clearErrorMessage() { + state.errorMessage = nil + } + + private func createFolder(name: String) { + do { + _ = try folderUseCase.create(name: name) + coordinator?.folderCreated() + } catch { + AppLogger.error(error) + state.errorMessage = error.localizedDescription + } + } +} + +extension NewFolderViewModel { + struct State { + var errorMessage: String? + } + + public enum Action { + public enum View { + case cancelButtonTapped + case createButtonTapped(name: String) + } + + case view(View) + } +} diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift new file mode 100644 index 00000000..62313336 --- /dev/null +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingStep.swift @@ -0,0 +1,110 @@ +import Core +import Foundation + +struct OnBoardingItem: Equatable { + let headline: String + let body: String + let image: String? + + init(headline: String, body: String, image: String? = nil) { + self.headline = headline + self.body = body + self.image = image + } +} + +enum Step: Int, CaseIterable, Equatable { + case first = 0 + 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: + OnBoardingItem( + headline: "녹음부터 요약까지,\n내 기기에서 한 번에", + body: "서버 업로드 없이 저장되는\n프라이빗 기록", + image: "onboarding01" + ) + case .second: + OnBoardingItem( + headline: "하루가 끝나면,\n기억은 먼저 정리돼버려요.", + body: "놓치고 싶지 않은 말들이 있다면,\n내 기기에 차곡차곡 기록하고 요약까지", + image: "onboarding02" + ) + case .micPermission: + OnBoardingItem( + headline: "필요한 권한만\n요청할게요.", + body: "녹음과 음성 변환을 위해\n마이크와 음성 인식 권한이 필요해요.", + image: "onboarding03" + ) + case .download: + OnBoardingItem( + headline: "기기에서 바로 작동하도록,\n몇 가지를 준비할게요.", + body: "녹음과 요약을 기기 안에서 처리하기 위해\n필요한 모델을 다운로드 해요.\nWi-Fi연결을 권장하며 몇 분 정도 걸려요." + ) + case .finish: + OnBoardingItem( + headline: "기록할 언어를 선택해 주세요.", + body: "텍스트 변환 정확도가 올라가요.\n언어는 나중에 변경할 수 있어요." + ) + } + } + + 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 + } + } + + func skip() -> Self { + if self == .first { + return .finish + } + return self + } +} diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift new file mode 100644 index 00000000..0a2f6775 --- /dev/null +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift @@ -0,0 +1,413 @@ +import Core +import Domain +import Foundation +import Observation + +@MainActor +public protocol OnboardingCoordinatorDelegate: AnyObject { + /// 온보딩 완료 시 화면 전환을 호출합니다. + func finishOnBoarding() +} + +@Observable +@MainActor +public final class OnBoardingViewModel { + // MARK: - Delegate + + public weak var onBoardingCoordinator: OnboardingCoordinatorDelegate? + + // MARK: - Dependencies + + let languageRepository: any LanguageRepository + let voiceRecordRepository: any VoiceRecordRepository + let sttRepository: any STTRepository + let checkFirstLaunchRepository: any CheckFirstLaunchRepository + let folderUseCase: any FolderUseCase + let availableSupportModelRepository: any AvailableModelSupportRepository + let mlxRepository: any OnDeviceRepository + + // MARK: - 생성자 + + public init( + languageRepository: any LanguageRepository, + voiceRecordRepository: any VoiceRecordRepository, + sttRepository: any STTRepository, + checkFirstLaunchRepository: any CheckFirstLaunchRepository, + folderUseCase: any FolderUseCase, + availableSupportModelRepository: any AvailableModelSupportRepository, + mlxRepository: any OnDeviceRepository + ) { + self.languageRepository = languageRepository + self.voiceRecordRepository = voiceRecordRepository + self.sttRepository = sttRepository + self.checkFirstLaunchRepository = checkFirstLaunchRepository + self.folderUseCase = folderUseCase + self.availableSupportModelRepository = availableSupportModelRepository + self.mlxRepository = mlxRepository + } + + // MARK: - State + + private(set) var currentStep: Step = .first + private(set) var errorMessage: String? + private(set) var language: Language = .ko + private(set) var modelSupport: Bool = false + private(set) var downloadTask: Task? + private(set) var status: OnDeviceStatus = .init( + storage: .notDownloaded + ) + private(set) var scrollEnabled: Bool = true + + private var isPaging: Bool = false + private(set) var steps: [Step] = Step.allCases + + var primaryButtonTitle: String { + switch currentStep { + case .finish: + return "시작하기" + case .download: + switch status.storage { + case .downloading: + return "다운로드 중입니다..." + case .downloaded: + return "다음" + case .failed: + return "재시도" + default: + return "다운로드" + } + default: + return "다음" + } + } + + var secondButtonTitle: String { + switch currentStep { + case .first: + return "건너뛰기" + case .finish: + return "" + case .download: + switch status.storage { + case .downloading: return "취소" + default: return "이전" + } + default: + return "이전" + } + } + + var isSecondButtonEnabled: Bool { + currentStep != .finish + } + + var isPrimaryButtonEnabled: Bool { + switch currentStep { + case .download: + switch status.storage { + case .downloading: + return false + default: + return true + } + default: + return true + } + } + + var isPrimaryButtonBgColor: Bool { + switch currentStep { + case .download, .finish: + return true + default: + return false + } + } + + // MARK: - Setters + + func setLanguage(_ val: Language) { + language = val + } + + // MARK: - Getters + + var currentStepIndex: Int { + steps.firstIndex(of: currentStep) ?? 0 + } +} + +// MARK: - Button Actions + +extension OnBoardingViewModel { + func primaryButtonAction(scrollAction: (Int) -> Void) { + guard !isPaging else { return } + switch currentStep { + case .finish: + isPaging = true + finishOnBoarding() + default: // 다음 + guard currentStep != .download else { + switch status.storage { + case .downloading: + return + case .downloaded: + return nextPage(scrollAction: scrollAction) + default: + return download() + } + } + return nextPage(scrollAction: scrollAction) + } + } + + func secondButtonAction(scrollAction: (Int) -> Void) { + guard !isPaging else { return } + switch currentStep { + case .first: // 건너뛰기 + let nextIndex = steps.firstIndex(of: .micPermission) ?? 0 + isPaging = true + scrollAction(nextIndex) + case .download: + switch status.storage { + case .downloading: + // 다운로드 중일 때는 다운로드 취소 + downloadTask?.cancel() + downloadTask = nil + default: + let nextIndex = currentStepIndex - 1 + guard nextIndex >= 0 else { return } + isPaging = true + scrollAction(nextIndex) + } + default: // 뒤로가기 + let nextIndex = currentStepIndex - 1 + guard nextIndex >= 0 else { return } + isPaging = true + scrollAction(nextIndex) + } + } +} + +// MARK: - Download Page State + +extension OnBoardingViewModel { + /// 온보딩 진입 시 Gemma4를 지원하는 기기인지 분기합니다. + func checkModelSupport() async { + let support = await availableSupportModelRepository.checkMLXSupportModel() + modelSupport = support.model == .gemma4_e2b_4bit + steps = modelSupport ? Step.allCases : Step.allCases.filter { $0 != .download } + if !steps.contains(currentStep) { + currentStep = .finish + } + } + + private func download() { + scrollEnabled = false + errorMessage = nil + downloadTask?.cancel() + downloadTask = Task { + defer { + scrollEnabled = true + if Task.isCancelled { + AppLogger.debug("Download Task Cancelled!!") + status = OnDeviceStatus(storage: .notDownloaded) + } + } + do { + self.status = OnDeviceStatus(storage: .downloading(progress: 0)) + try await mlxRepository.download { progress in + Task { @MainActor in + guard case .downloading = self.status.storage else { return } + self.status = OnDeviceStatus(storage: .downloading(progress: progress)) + } + } + self.status = OnDeviceStatus(storage: .downloaded) + } catch let repoError as OnDeviceRepositoryError { + AppLogger.error(repoError) + if case .cancelled = repoError { + self.status = OnDeviceStatus(storage: .notDownloaded) + } else { + self.errorMessage = repoError.errorDescription + AppLogger.info(errorMessage ?? "nil") + self.status = OnDeviceStatus(storage: .failed) + } + } catch { + AppLogger.error(error) + self.errorMessage = error.localizedDescription + self.status = OnDeviceStatus(storage: .failed) + } + } + } +} + +#if DEBUG + public extension OnBoardingViewModel { + /// SwiftUI Preview에서 사용할 수 있는 가상 뷰모델 인스턴스를 생성합니다. + static func preview() -> OnBoardingViewModel { + return OnBoardingViewModel( + languageRepository: PreviewLanguageRepository(), + voiceRecordRepository: PreviewVoiceRecordRepository(), + sttRepository: PreviewSTTRepository(), + checkFirstLaunchRepository: PreviewCheckFirstLaunchRepository(), + folderUseCase: PreviewFolderUseCase(), + availableSupportModelRepository: PreviewAvailableModelSupportRepository(), + mlxRepository: PreviewOnDeviceRepository() + ) + } + } + + private extension OnBoardingViewModel { + struct PreviewLanguageRepository: LanguageRepository { + func fetchLanguage() -> Language { .ko } + func saveLanguage(_ language: Language) {} + } + + struct PreviewVoiceRecordRepository: VoiceRecordRepository { + func checkMicrophonePermission() -> PermissionStatus { .authorized } + func requestMicrophonePermission() async throws(VoiceRecordRepositoryError) + -> PermissionStatus { .authorized } + func startRecording() async throws(VoiceRecordRepositoryError) -> AsyncStream { .init { _ in } } + func pauseRecording() async throws(VoiceRecordRepositoryError) {} + func resumeRecording() async throws(VoiceRecordRepositoryError) {} + func finishRecording() async throws(VoiceRecordRepositoryError) -> VoiceRecord { + VoiceRecord(audioFilePath: "", duration: 0) + } + + func cancelRecording() async throws(VoiceRecordRepositoryError) {} + } + + struct PreviewSTTRepository: STTRepository { + func transcribe(audioFilePath: String) async throws(STTRepositoryError) -> Transcript { Transcript() } + func checkSTTPermission() -> PermissionStatus { .authorized } + func requestSTTPermission() async throws(STTPermissionRepositoryError) -> PermissionStatus { .authorized } + } + + struct PreviewCheckFirstLaunchRepository: CheckFirstLaunchRepository { + func checkIsFirstLaunch() -> Bool { true } + func checkAndMarkFirstLaunch() -> Bool { true } + } + + struct PreviewFolderUseCase: FolderUseCase { + func create(name: String) throws(FolderUseCaseError) -> Folder { Folder(name: name, kind: .custom) } + func createDefault() throws(FolderUseCaseError) -> Folder { Folder(name: "기본", kind: .default) } + func createTrash() throws(FolderUseCaseError) -> Folder { Folder(name: "휴지통", kind: .trash) } + func fetchAll() throws(FolderUseCaseError) -> [Folder] { [] } + func fetchDefault() throws(FolderUseCaseError) -> Folder { Folder(name: "기본", kind: .default) } + func fetchTrash() throws(FolderUseCaseError) -> Folder { Folder(name: "휴지통", kind: .trash) } + func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] { [] } + func fetch(by id: UUID) throws(FolderUseCaseError) -> Folder { Folder(name: "테스트", kind: .custom) } + func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder { folder } + func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { .init { _ in } } + func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { .init { _ in } } + func moveToTrash(folderID: UUID) throws(FolderUseCaseError) {} + func restore(folderID: UUID) throws(FolderUseCaseError) {} + func delete(folderID: UUID) throws(FolderUseCaseError) {} + } + + struct PreviewAvailableModelSupportRepository: AvailableModelSupportRepository { + func checkMLXSupportModel() async -> ChaGokModelSupport { + ChaGokModelSupport(ramSizeGB: 8, isProUser: false) + } + + func fetchSupportModels() async -> [ChaGokModelState] { + [] + } + } + + struct PreviewOnDeviceRepository: OnDeviceRepository { + func checkStatus() async -> Domain.OnDeviceStatus { + .init(storage: .downloaded) + } + + func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { + do { + // 0%에서 100%까지 0.5초 간격으로 진행률을 올려 취소를 테스트할 충분한 시간을 줍니다. + for progress in stride(from: 0.0, through: 1.0, by: 0.1) { + try await Task.sleep(nanoseconds: 500_000_000) // 0.5초 간격 + try Task.checkCancellation() + progressHandler(progress) + } + } catch is CancellationError { + throw .cancelled + } catch { + throw .unknown(error) + } + } + + func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus { + OnDeviceStatus(storage: .notDownloaded) + } + } + } +#endif + +// MARK: - Delegate Helper Function + +extension OnBoardingViewModel { + /// 스크롤 뷰의 현재 offset을 기준으로 currentStep과 pagenation을 동기화합니다. + /// 스와이프(1칸)든 건너뛰기(여러 칸)든 모든 페이지 전환이 이 함수를 통해 처리됩니다. + func syncPageState(nextStep: Int) { + defer { isPaging = false } + guard steps.indices.contains(nextStep) else { return } + let targetStep = steps[nextStep] + guard targetStep != currentStep else { return } + currentStep = targetStep + if currentStep == .micPermission { + requestPermission() + } + } + + private func nextPage(scrollAction: (Int) -> Void) { + let nextIndex = currentStepIndex + 1 + guard nextIndex < steps.count else { return } + isPaging = true + scrollAction(nextIndex) + } +} + +// MARK: - UseCase 비동기 함수 + +extension OnBoardingViewModel { + private func requestPermission() { + Task { + // 마이크 권한 요청 + let micStatus = voiceRecordRepository.checkMicrophonePermission() + if micStatus == .notDetermined { + do { + _ = try await voiceRecordRepository.requestMicrophonePermission() + } catch { + errorMessage = error.localizedDescription + AppLogger.error(error) + } + } + + // STT 권한 요청 + let sttStatus = sttRepository.checkSTTPermission() + if sttStatus == .notDetermined { + do { + _ = try await sttRepository.requestSTTPermission() + } catch { + // STT 권한 에러는 마이크 권한 에러를 덮어쓰지 않도록 함 (필요시 추가 처리 가능) + AppLogger.error(error) + } + } + } + } + + private func finishOnBoarding() { + Task { + do { + languageRepository.saveLanguage(language) + _ = try folderUseCase.createDefault() + _ = try folderUseCase.createTrash() + _ = checkFirstLaunchRepository.checkAndMarkFirstLaunch() + onBoardingCoordinator?.finishOnBoarding() + } catch { + isPaging = false + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + } +} diff --git a/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift new file mode 100644 index 00000000..f977c408 --- /dev/null +++ b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift @@ -0,0 +1,114 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol DownloadOnDeviceCoordinatorDelegate: AnyObject { + /// 시트를 닫습니다 + /// - Parameter completion: true: 다운로드 완료 / false: 취소 또는 나중에 + func dismissSheet() +} + +@MainActor +@Observable +public final class DownloadOnDeviceViewModel { + // MARK: - State + + /// 온디바이스 모델의 통합 상태값 + private(set) var status: OnDeviceStatus = .init(storage: .notDownloaded) + private(set) var errorMessage: String? + + public weak var coordinator: DownloadOnDeviceCoordinatorDelegate? + private let onDeviceStatusUseCase: any OnDeviceStatusUseCase + + @ObservationIgnored + private var statusObservationTask: Task? + @ObservationIgnored + private var downloadTask: Task? + + /// UI Binding을 위해 status로부터 파생된 연산 프로퍼티들 + var isDownloading: Bool { + if case .downloading = status.storage { return true } + return false + } + + // MARK: - Initialize + + public init( + onDeviceStatusUseCase: any OnDeviceStatusUseCase + ) { + self.onDeviceStatusUseCase = onDeviceStatusUseCase + observeDownloadStatus() + } +} + +// MARK: - Actions + +extension DownloadOnDeviceViewModel { + /// 온디바이스 모델(Whisper)의 상태 스트림을 구독하여 상태를 관찰합니다. + private func observeDownloadStatus() { + statusObservationTask?.cancel() + statusObservationTask = Task { [weak self] in + guard let self else { return } + let stream = await onDeviceStatusUseCase.subscribe(model: .whisper) + for await newStatus in stream { + status = newStatus + AppLogger.debug("OnDeviceStatus: \(newStatus)") + if newStatus.storage == .downloaded { + dismiss() + } + } + } + } + + /// 모델의 다운로드를 유즈케이스에 요청합니다. + func download() { + guard downloadTask == nil else { return } + errorMessage = nil + + downloadTask = Task { + defer { downloadTask = nil } + do { + try await onDeviceStatusUseCase.download(model: .whisper) + } catch { + // 사용자 명시적 취소(.cancelled)인 경우 에러 메시지를 표시하지 않고 무시 + if case .cancelled = error as? OnDeviceStatusUseCaseError { + return + } + self.errorMessage = error.localizedDescription + } + } + } + + func cancelDownload() { + let task = downloadTask + downloadTask = nil + status = OnDeviceStatus(storage: .notDownloaded) + + let useCase = onDeviceStatusUseCase + Task { + task?.cancel() + try? await useCase.delete(model: .whisper) + } + } + + func dismiss() { + statusObservationTask?.cancel() + + let task = downloadTask + downloadTask = nil + + // 다운로드가 완전히 완료되지 않은 상태(예: 취소 상태)에서 해제될 때만 + // 유즈케이스의 저장 캐시 및 디스크 상태를 완전히 초기화(notDownloaded)합니다. + if status.storage != .downloaded { + let useCase = onDeviceStatusUseCase + Task { + task?.cancel() + try? await useCase.delete(model: .whisper) + } + } else { + task?.cancel() + } + coordinator?.dismissSheet() + } +} diff --git a/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift new file mode 100644 index 00000000..bf541d7f --- /dev/null +++ b/Presentation/Sources/ViewModel/Recording/RecordingViewModel.swift @@ -0,0 +1,189 @@ +import Domain +import Foundation + +@MainActor +public protocol RecordingCoordinating: AnyObject { + func cancelRecording() + func finishRecording(voiceNote: VoiceNote) +} + +@MainActor +@Observable +public final class RecordingViewModel { + struct State: Equatable { + enum RecordingState { + case idle + case recording + case paused + } + + let title: String = "새 기록" + let cancelTitle: String = "취소" + let completeTitle: String = "종료" + var recordingStartDate: Date = .now + var recordingDuration: TimeInterval = 0 + var amplitude: Float = 0 + var recordingState: RecordingState = .idle + var errorMessage: String? + + var displayStartDate: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy.MM.dd · a HH:mm" + + return formatter.string(from: recordingStartDate) + } + + var displayDuration: String { + let duration = Int(recordingDuration) + let hours = duration / 3600 + let minutes = (duration % 3600) / 60 + let seconds = duration % 60 + + return String(format: "%02d : %02d : %02d", hours, minutes, seconds) + } + } + + public enum Action { + case viewDidAppear + case recordButtonTapped + case openCancelAlertButtonTapped + case openCompleteAlertButtonTapped + case cancelButtonTapped + case finishButtonTapped + case errorOccurred(Error) + } + + private let repository: any VoiceRecordRepository + private let voiceNoteUseCase: any VoiceNoteUseCase + + public weak var coordinator: RecordingCoordinating? + public weak var alertCoordinator: ChaGokAlertCoordinatorDelegate? + public var showCancelAlert: (() -> Void)? + public var showCompleteAlert: (() -> Void)? + + var state: State = .init() + private var waveformTask: Task? + private var timerTask: Task? + private var actionTask: Task? + + public init( + repository: any VoiceRecordRepository, + voiceNoteUseCase: any VoiceNoteUseCase + ) { + self.repository = repository + self.voiceNoteUseCase = voiceNoteUseCase + } + + public func send(_ action: Action) { + switch action { + case .viewDidAppear: + guard state.recordingState == .idle else { return } + startRecording() + case .recordButtonTapped: + switch state.recordingState { + case .paused: + resumeRecording() + case .recording: + pauseRecording() + case .idle: + startRecording() + } + case .openCancelAlertButtonTapped: + if state.recordingDuration <= 3 { + send(.cancelButtonTapped) + } else { + showCancelAlert?() + } + case .openCompleteAlertButtonTapped: + showCompleteAlert?() + case .cancelButtonTapped: + stopTimer() + waveformTask?.cancel() + waveformTask = nil + actionTask?.cancel() + actionTask = Task { + try? await repository.cancelRecording() + coordinator?.cancelRecording() + } + case .finishButtonTapped: + actionTask?.cancel() + actionTask = Task { + do { + stopTimer() + waveformTask?.cancel() + waveformTask = nil + let voiceRecord = try await repository.finishRecording() + let voiceNote = try voiceNoteUseCase.create(voiceRecord) + coordinator?.finishRecording(voiceNote: voiceNote) + } catch { + send(.errorOccurred(error)) + } + } + case .errorOccurred(let error): + state.errorMessage = error.localizedDescription + } + } + + private func startRecording() { + Task { + do { + let waveformStream = try await repository.startRecording() + state.recordingStartDate = .now + state.recordingState = .recording + startTimer() + + waveformTask?.cancel() + waveformTask = Task { [weak self] in + for await waveform in waveformStream { + guard let self else { break } + state.amplitude = waveform.amplitudes.last ?? 0 + } + } + } catch { + state.recordingState = .idle + send(.errorOccurred(error)) + } + } + } + + private func pauseRecording() { + Task { + do { + try await repository.pauseRecording() + stopTimer() + state.recordingState = .paused + } catch { + send(.errorOccurred(error)) + } + } + } + + private func resumeRecording() { + Task { + do { + try await repository.resumeRecording() + startTimer() + state.recordingState = .recording + } catch { + send(.errorOccurred(error)) + } + } + } + + private func startTimer() { + timerTask?.cancel() + timerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, let self else { return } + state.recordingDuration += 1 + } + } + } + + private func stopTimer() { + timerTask?.cancel() + timerTask = nil + } +} diff --git a/Presentation/Sources/ViewModel/Search/SearchViewModel.swift b/Presentation/Sources/ViewModel/Search/SearchViewModel.swift new file mode 100644 index 00000000..c50e1b3e --- /dev/null +++ b/Presentation/Sources/ViewModel/Search/SearchViewModel.swift @@ -0,0 +1,179 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol SearchCoordinatorDelegate: AnyObject { + /// 뒤로 가기 + func pop() + /// 폴더 Push + func pushMyFolderDetailView(_ folder: Folder, isTrashMode: Bool) + /// 음성 노트 Push + func pushVoiceNoteView(voiceNote: VoiceNote, isTrashMode: Bool) +} + +@MainActor +@Observable +public final class SearchViewModel { + // MARK: - Search State + + enum SearchState { + case empty // 검색 전 + case emptyResult // 검색 결과 없음 + case result // 검색 결과 있음 + } + + public enum SearchType: Equatable { + case main // 메인 + case myFolder // 폴더 목록 + case myDetailFolder(String) // 상세 폴더 + case trash // 휴지통 + + var title: String { + switch self { + case .main: + "전체" + case .myFolder: + "폴더 목록" + case .myDetailFolder(let name): + name + case .trash: + "휴지통" + } + } + } + + // MARK: - State + + @ObservationIgnored + private(set) var items: [ContentItem] + @ObservationIgnored + let type: SearchType + public private(set) var isTrashMode: Bool = false + private(set) var searchState: SearchState = .empty + private(set) var filteredItems: [ContentItem] = [] + private(set) var query: String = "" + public weak var coordinator: SearchCoordinatorDelegate? + private let folderRepository: FolderRepository + + // MARK: Initialize + + public init( + type: SearchType, + items: [ContentItem], + isTrashMode: Bool = false, + folderRepository: FolderRepository + ) { + self.type = type + self.items = items + self.isTrashMode = isTrashMode + self.folderRepository = folderRepository + } + + // MARK: - Action + + func search(_ query: String) { + self.query = query + + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { + searchState = .empty + filteredItems = [] + return + } + + let results = items.filter { item in + switch item { + case .folder(let folder): + return folder.name.localizedCaseInsensitiveContains(query) + case .voiceNote(let voiceNote): + return voiceNote.title.localizedCaseInsensitiveContains(query) + } + } + + filteredItems = results + searchState = results.isEmpty ? .emptyResult : .result + } + + func parentFolder(id: UUID) -> String { + do { + let folder: Folder = try folderRepository.fetch(by: id) + return folder.name + } catch { + AppLogger.error(error) + } + + return "" + } + + func clearSearch() { + coordinator?.pop() + } + + // MARK: - Coordinator + + func pushFolder(_ folder: Folder) { + coordinator?.pushMyFolderDetailView(folder, isTrashMode: isTrashMode) + } + + func pushVoiceNote(_ voiceNote: VoiceNote) { + coordinator?.pushVoiceNoteView(voiceNote: voiceNote, isTrashMode: isTrashMode) + } +} + +#if DEBUG + public extension SearchViewModel { + static func preview() -> SearchViewModel { + SearchViewModel( + type: .main, + items: [ + .folder(.init(name: "쓰레기 1")), + .folder(.init(name: "쓰레기 2")), + .folder(.init(name: "쓰레기 3")), + .voiceNote( + .init( + title: "음성", + folderID: UUID(), + voiceRecord: VoiceRecord( + audioFilePath: "qwe", duration: 23.0 + ), + analysisState: .completed + ) + ) + ], + folderRepository: PreviewFolderRepository() + ) + } + } + + private struct PreviewFolderRepository: FolderRepository { + func create(_ folder: Folder) throws(FolderRepositoryError) -> Folder { + folder + } + + func fetchAll() throws(FolderRepositoryError) -> [Folder] { + [] + } + + func fetch(by id: UUID) throws(FolderRepositoryError) -> Folder { + Folder(name: "미리보기 폴더", kind: .custom) + } + + func fetch(by kind: FolderKind) throws(FolderRepositoryError) -> [Folder] { + [] + } + + func update(_ folder: Folder) throws(FolderRepositoryError) -> Folder { + folder + } + + func observe(by kind: FolderKind) throws(FolderRepositoryError) -> AsyncStream<[Folder]> { + AsyncStream { $0.finish() } + } + + func observeTrashed() throws(FolderRepositoryError) -> AsyncStream<[Folder]> { + AsyncStream { $0.finish() } + } + + func delete(id: UUID) throws(FolderRepositoryError) {} + } +#endif diff --git a/Presentation/Sources/ViewModel/SelectionMode.swift b/Presentation/Sources/ViewModel/SelectionMode.swift new file mode 100644 index 00000000..5e40e803 --- /dev/null +++ b/Presentation/Sources/ViewModel/SelectionMode.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum SelectionMode: Equatable { + case none // 선택 모드 아님 + case multiple // 선택 모드 (일부 항목 선택됨) + case all // 전체 선택 모드 +} diff --git a/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift b/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift new file mode 100644 index 00000000..fdab2166 --- /dev/null +++ b/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift @@ -0,0 +1,73 @@ +import Core +import Domain +import Foundation + +#if DEBUG + extension SettingViewModel { + static var preview: SettingViewModel { + SettingViewModel( + languageRepository: PreviewLanguageRepository(language: .ko), + availableModelRepository: PreviewAvailableModelSupportRepository(), + onDeviceStatusUseCase: PreviewOnDeviceStatusUseCase() + ) + } + + final class PreviewLanguageRepository: LanguageRepository, @unchecked Sendable { + var language: Language + + init(language: Language) { + self.language = language + } + + func fetchLanguage() -> Language { + language + } + + func saveLanguage(_ language: Language) { + self.language = language + } + } + + struct PreviewAvailableModelSupportRepository: AvailableModelSupportRepository { + func checkMLXSupportModel() async -> ChaGokModelSupport { + ChaGokModelSupport(ramSizeGB: 8, isProUser: false) + } + + func fetchSupportModels() async -> [ChaGokModelState] { + [ + ChaGokModelState( + title: "Gemma-4", + subTitle: "AI 요약 모델", + model: .gemma4_e2b_4bit, + status: OnDeviceStatus(storage: .downloaded) + ), + ChaGokModelState( + title: "Whisper", + subTitle: "음성 전사 모델", + model: .whisper, + status: OnDeviceStatus(storage: .downloaded) + ) + ] + } + } + + actor PreviewOnDeviceStatusUseCase: OnDeviceStatusUseCase { + func checkStatus(model: Domain.ChaGokModel) async -> Domain.OnDeviceStatus { + .init(storage: .downloaded) + } + + func cancelDownload(model: Domain.ChaGokModel) async {} + + func subscribe(model: ChaGokModel) async -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + continuation.yield(OnDeviceStatus(storage: .downloaded)) + continuation.finish() + } + } + + func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) {} + + func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) {} + } + } +#endif diff --git a/Presentation/Sources/ViewModel/Setting/SettingViewModel.swift b/Presentation/Sources/ViewModel/Setting/SettingViewModel.swift new file mode 100644 index 00000000..45b800d1 --- /dev/null +++ b/Presentation/Sources/ViewModel/Setting/SettingViewModel.swift @@ -0,0 +1,151 @@ +import Core +import Domain +import Observation + +@MainActor +public protocol SettingCoordinatorDelegate: AnyObject { + /// 뒤로가기 + func pop() + /// 이용약관 push + func pushTermsOfUseView() + /// 개인정보 처리 방침 push + func pushPrivacyPolicyView() +} + +@MainActor +@Observable +public final class SettingViewModel { + private let languageRepository: any LanguageRepository + private let availableModelRepository: any AvailableModelSupportRepository + private let onDeviceStatusUseCase: any OnDeviceStatusUseCase + + public weak var coordinator: SettingCoordinatorDelegate? + + // MARK: - State + + private(set) var language: Language + private(set) var models: [ChaGokModelState] = [] + + public init( + languageRepository: any LanguageRepository, + availableModelRepository: any AvailableModelSupportRepository, + onDeviceStatusUseCase: any OnDeviceStatusUseCase + ) { + self.languageRepository = languageRepository + self.availableModelRepository = availableModelRepository + self.onDeviceStatusUseCase = onDeviceStatusUseCase + language = languageRepository.fetchLanguage() + } + + private var observationTasks: [ChaGokModel: Task] = [:] + + // MARK: - Setter / Getter + + func setLanguage(_ lang: Language) { + language = lang + languageRepository.saveLanguage(lang) + } + + // MARK: - Actions + + func checkModels() { + Task { + self.models = await availableModelRepository.fetchSupportModels() + observeDownloadStatus() + } + } + + func downloadModel(model: ChaGokModel) { + guard model != .none else { return } + Task { + do { + try await onDeviceStatusUseCase.download(model: model) + } catch { + AppLogger.error(error) + } + } + } + + func deleteModel(model: ChaGokModel) { + guard model != .none else { return } + Task { + do { + try await onDeviceStatusUseCase.delete(model: model) + } catch { + AppLogger.error(error) + } + } + } + + func pop() { + coordinator?.pop() + } + + func pushTermsOfUse() { + coordinator?.pushTermsOfUseView() + } + + func pushPrivacyPolicy() { + coordinator?.pushPrivacyPolicyView() + } + + // MARK: - Private Observation + + private func observeDownloadStatus() { + for task in observationTasks.values { + task.cancel() + } + observationTasks.removeAll() + + for modelState in models { + let model = modelState.model + guard model != .none else { continue } + + observationTasks[model] = Task { [weak self] in + let stream = await self?.onDeviceStatusUseCase.subscribe(model: model) + guard let stream else { return } + for await newStatus in stream { + self?.updateModelStatus(model: model, status: newStatus) + } + } + } + } + + private func updateModelStatus(model: ChaGokModel, status: OnDeviceStatus) { + if let index = models.firstIndex(where: { $0.model == model }) { + let currentStatus = models[index].status + if case .downloading = currentStatus.storage, case .downloading = status.storage { + return + } + models[index].status = status + } + } +} + +// MARK: - Data + +extension SettingViewModel { + enum Section: Hashable { + case lang + case model + case label + } + + struct Item: Hashable { + let title: String + let subTitle: String? + let data: ItemData + } + + enum ItemData: Hashable { + case lang(Language) + case model([ChaGokModelState]) + case none(LabelData) + } + + enum LabelData: Hashable { + case termsOfUse // 이용약관 + case privacyPolicy // 개인 정보 처리 방침 + case customerInquiry // 고객 문의 + } +} diff --git a/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift b/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift new file mode 100644 index 00000000..a97a9af7 --- /dev/null +++ b/Presentation/Sources/ViewModel/Trash/TrashViewModel.swift @@ -0,0 +1,456 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol TrashCoordinatorDelegate: AnyObject { + /// 뒤로가기 + func pop() + /// 음성 노트 Push + func pushVoiceNoteView(voiceNote: VoiceNote, isTrashMode: Bool) + /// 상세 폴더 Push + func pushMyFolderDetailView(_ folder: Folder, isHidden: Bool) + /// 검색 화면 Push함수 + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem], isHidden: Bool) +} + +@MainActor +@Observable +public final class TrashViewModel { + // MARK: - State + + private(set) var isHidden: Bool = true + private(set) var items: [ContentItem] = [] + private(set) var errorMessage: String? + private(set) var select: SelectionMode = .none + private(set) var selectedItems: [ContentItem] = [] + public weak var coordinator: TrashCoordinatorDelegate? + public weak var alertCoordinator: ChaGokAlertCoordinatorDelegate? + + @ObservationIgnored + private var foldersObservationTask: Task? + @ObservationIgnored + private var notesObservationTask: Task? + + @ObservationIgnored + private var trashedFolders: [Folder] = [] + @ObservationIgnored + private var trashedNotes: [VoiceNote] = [] + + // MARK: - UseCase + + private let folderUseCase: any FolderUseCase + private let voiceNoteUseCase: any VoiceNoteUseCase + + // MARK: - Initialize + + public init( + folderUseCase: any FolderUseCase, + voiceNoteUseCase: any VoiceNoteUseCase + ) { + self.folderUseCase = folderUseCase + self.voiceNoteUseCase = voiceNoteUseCase + } +} + +// MARK: - Setter / Getter + +extension TrashViewModel { + func setSelectionMode(_ select: SelectionMode) { + self.select = select + if select == .none { + allClearSelected() + } else if select == .all { + allSelected() + } + } + + private func allSelected() { + selectedItems = items.compactMap { + switch $0 { + case .folder(let folder): + return .folder(folder) + case .voiceNote(let voiceNote): + return .voiceNote(voiceNote) + } + } + } + + private func allClearSelected() { + selectedItems.removeAll() + } +} + +// MARK: Action + +extension TrashViewModel { + func didTapBack() { + coordinator?.pop() + } + + func pushVoiceNote(_ voiceNote: VoiceNote) { + coordinator?.pushVoiceNoteView(voiceNote: voiceNote, isTrashMode: true) + } + + func pushDetailFolder(_ folder: Folder) { + coordinator?.pushMyFolderDetailView(folder, isHidden: isHidden) + } + + func pushSearch() { + coordinator?.pushSearchView(type: .trash, items: items, isHidden: isHidden) + } + + func selectItem(_ item: ContentItem) { + if select == .none { setSelectionMode(.multiple) } + selectedItems.append(item) + } + + func deselectItem(_ item: ContentItem) { + selectedItems.removeAll { $0.id == item.id } + } + + func deleteButtonTapped(alertAction: () -> Void) { + guard !selectedItems.isEmpty else { + setSelectionMode(.none) + return + } + alertAction() + } +} + +// MARK: - Lifecycle + +extension TrashViewModel { + func onAppear() { + guard foldersObservationTask == nil, notesObservationTask == nil else { return } + do { + let foldersStream = try folderUseCase.observeTrashed() + let notesStream = try voiceNoteUseCase.observeTrashed() + foldersObservationTask = Task { [weak self] in + for await folders in foldersStream { + self?.applyTrashedFolders(folders) + } + } + notesObservationTask = Task { [weak self] in + for await notes in notesStream { + self?.applyTrashedNotes(notes) + } + } + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + func onDisappear() { + foldersObservationTask?.cancel() + foldersObservationTask = nil + notesObservationTask?.cancel() + notesObservationTask = nil + } + + private func applyTrashedFolders(_ folders: [Folder]) { + trashedFolders = folders + refreshItems() + } + + private func applyTrashedNotes(_ notes: [VoiceNote]) { + trashedNotes = notes + refreshItems() + } + + private func refreshItems() { + items = trashedFolders.map(ContentItem.folder) + trashedNotes.map(ContentItem.voiceNote) + sortItems() + } + + private func sortItems() { + items.sort { lhs, rhs -> Bool in + let lhsDate = lhs.deletedAt ?? .distantPast + let rhsDate = rhs.deletedAt ?? .distantPast + return lhsDate > rhsDate + } + } +} + +// MARK: - Delete + +extension TrashViewModel { + func deleteAll() { + do { + for item in items { + try deleteOne(item) + } + items.removeAll() + setSelectionMode(.none) + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + func delete(item: ContentItem) { + do { + try deleteOne(item) + items.removeAll { $0.id == item.id } + setSelectionMode(.none) + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + func delete(items deleteItems: [ContentItem]) { + do { + for item in deleteItems { + try deleteOne(item) + } + let deleteIDs = Set(deleteItems.map(\.id)) + items.removeAll { deleteIDs.contains($0.id) } + setSelectionMode(.none) + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + private func deleteOne(_ item: ContentItem) throws { + switch item { + case .folder(let folder): + try folderUseCase.delete(folderID: folder.id) + case .voiceNote(let note): + try voiceNoteUseCase.delete(noteID: note.id) + } + } +} + +// MARK: - Restore + +extension TrashViewModel { + func restore(item: ContentItem) { + do { + try restoreOne(item) + items.removeAll { $0.id == item.id } + setSelectionMode(.none) + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + func restore(items restoreItems: [ContentItem]) { + do { + for item in restoreItems { + try restoreOne(item) + } + let restoreIDs = Set(restoreItems.map(\.id)) + items.removeAll { restoreIDs.contains($0.id) } + setSelectionMode(.none) + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + func cancelRestore(item: ContentItem) { + do { + try moveToTrashOne(item) + items.append(item) + sortItems() + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + func cancelRestore(items restoreItems: [ContentItem]) { + do { + for item in restoreItems { + try moveToTrashOne(item) + } + items.append(contentsOf: restoreItems) + sortItems() + } catch { + AppLogger.error(error) + errorMessage = error.localizedDescription + } + } + + private func restoreOne(_ item: ContentItem) throws { + switch item { + case .folder(let folder): + try folderUseCase.restore(folderID: folder.id) + case .voiceNote(let note): + try voiceNoteUseCase.restore(noteID: note.id) + } + } + + private func moveToTrashOne(_ item: ContentItem) throws { + switch item { + case .folder(let folder): + try folderUseCase.moveToTrash(folderID: folder.id) + case .voiceNote(let note): + try voiceNoteUseCase.moveToTrash(noteID: note.id) + } + } +} + +#if DEBUG + extension TrashViewModel { + static func preview() -> TrashViewModel { + let previewData = PreviewData.make() + let viewModel = TrashViewModel( + folderUseCase: PreviewFolderUseCase(trashedFolders: previewData.folders), + voiceNoteUseCase: PreviewVoiceNoteUseCase(trashedNotes: previewData.notes) + ) + viewModel.onAppear() + return viewModel + } + } + + private extension TrashViewModel { + struct PreviewData { + let folders: [Folder] + let notes: [VoiceNote] + + static func make(now: Date = .now) -> Self { + var folders: [Folder] = [] + var notes: [VoiceNote] = [] + for index in 0 ..< 10 { + if index.isMultiple(of: 2) { + let createdOffset = TimeInterval((index + 2) * 43200) * -1 + let updatedOffset = TimeInterval((index + 1) * 21600) * -1 + let deletedOffset = TimeInterval((index + 1) * 10800) * -1 + + notes.append( + VoiceNote( + title: "휴지통 메모 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + updatedAt: now.addingTimeInterval(updatedOffset), + folderID: UUID(), + voiceRecord: VoiceRecord( + createdAt: now.addingTimeInterval(createdOffset), + audioFilePath: "VoiceRecords/preview-\(index).m4a", + duration: Double(120 + index * 15) + ), + transcript: nil, + summary: nil, + deletedAt: now.addingTimeInterval(deletedOffset), + analysisState: .pending + ) + ) + } else { + let createdOffset = TimeInterval((index + 1) * 64800) * -1 + let deletedOffset = TimeInterval((index + 1) * 10800) * -1 + folders.append( + Folder( + name: "휴지통 폴더 \(index + 1)", + createdAt: now.addingTimeInterval(createdOffset), + kind: .custom, + deletedAt: now.addingTimeInterval(deletedOffset) + ) + ) + } + } + return PreviewData(folders: folders, notes: notes) + } + } + + struct PreviewFolderUseCase: FolderUseCase { + let trashedFolders: [Folder] + + func create(name: String) throws(FolderUseCaseError) -> Folder { + Folder(name: name, kind: .custom) + } + + func createDefault() throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func createTrash() throws(FolderUseCaseError) -> Folder { + Folder(name: "휴지통", kind: .trash) + } + + func fetchAll() throws(FolderUseCaseError) -> [Folder] { + [] + } + + func fetchDefault() throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func fetchTrash() throws(FolderUseCaseError) -> Folder { + Folder(name: "휴지통", kind: .trash) + } + + func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] { + [] + } + + func fetch(by _: UUID) throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder { + folder + } + + func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + AsyncStream { $0.finish() } + } + + func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + let snapshot = trashedFolders + return AsyncStream { continuation in + continuation.yield(snapshot) + continuation.finish() + } + } + + func moveToTrash(folderID _: UUID) throws(FolderUseCaseError) {} + func restore(folderID _: UUID) throws(FolderUseCaseError) {} + func delete(folderID _: UUID) throws(FolderUseCaseError) {} + } + + struct PreviewVoiceNoteUseCase: VoiceNoteUseCase { + let trashedNotes: [VoiceNote] + + func create(_ voiceRecord: VoiceRecord) throws(VoiceNoteUseCaseError) -> VoiceNote { + VoiceNote(title: "미리보기", folderID: UUID(), voiceRecord: voiceRecord, analysisState: .pending) + } + + func fetch(byId id: UUID) throws(VoiceNoteUseCaseError) -> VoiceNote { + throw .recordNotFound(id) + } + + func update(_ voiceNote: VoiceNote) throws(VoiceNoteUseCaseError) -> VoiceNote { + voiceNote + } + + func observe(id: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream { + AsyncStream { $0.finish() } + } + + func observe(folderID _: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + AsyncStream { $0.finish() } + } + + func observeRecent(limit _: Int) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + AsyncStream { $0.finish() } + } + + func observeTrashed() throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let snapshot = trashedNotes + return AsyncStream { continuation in + continuation.yield(snapshot) + continuation.finish() + } + } + + func regenerateSummary(id _: UUID) {} + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + } + } +#endif diff --git a/Presentation/Sources/ViewModel/VoiceNote/KeyPoint.swift b/Presentation/Sources/ViewModel/VoiceNote/KeyPoint.swift new file mode 100644 index 00000000..d89922c6 --- /dev/null +++ b/Presentation/Sources/ViewModel/VoiceNote/KeyPoint.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct KeyPoint: Hashable { + let number: Int + let text: String +} diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteSearchMatch.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteSearchMatch.swift new file mode 100644 index 00000000..7d1f3ac6 --- /dev/null +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteSearchMatch.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct VoiceNoteSearchMatch: Hashable, Sendable { + public enum Location: Hashable, Sendable { + case keyPoint(index: Int) + case keyword(index: Int) + case script(sectionIndex: Int) + } + + public let location: Location + public let range: NSRange + + public init(location: Location, range: NSRange) { + self.location = location + self.range = range + } +} diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift new file mode 100644 index 00000000..3e8733f7 --- /dev/null +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel+Preview.swift @@ -0,0 +1,185 @@ +#if DEBUG + import Domain + import Foundation + + extension VoiceNoteViewModel { + static func preview() -> VoiceNoteViewModel { + let noteID = UUID() + let voiceNote = VoiceNote( + id: noteID, + title: "미리보기 회의록", + createdAt: Date.now.addingTimeInterval(-3600), + updatedAt: Date.now.addingTimeInterval(-1800), + folderID: UUID(), + voiceRecord: VoiceRecord(audioFilePath: "preview.m4a", duration: 245), + keywords: [ + Keyword(noteID: noteID, word: "디자인"), + Keyword(noteID: noteID, word: "회의"), + Keyword(noteID: noteID, word: "마감"), + Keyword(noteID: noteID, word: "일정") + ], + transcript: Transcript(sections: [ + TranscriptSection( + timestamp: 0, + text: "오늘 회의는 다음 주 디자인 마감 일정을 정리하는 자리였습니다." + ), + TranscriptSection( + timestamp: 45, + text: "주요 컴포넌트 세 가지를 먼저 마무리하기로 했고, 나머지 항목은 추후 논의합니다." + ), + TranscriptSection( + timestamp: 120, + text: "다음 미팅은 수요일 오후로 예정되어 있습니다." + ) + ]), + summary: Summary( + text: "다음 주 디자인 마감 일정 확정\n주요 컴포넌트 세 가지 우선 처리\n수요일 오후 추가 미팅" + ), + analysisState: .completed + ) + return VoiceNoteViewModel( + voiceNote: voiceNote, + voiceNoteUseCase: PreviewVoiceNoteUseCase(items: [voiceNote]), + folderUseCase: PreviewFolderUseCase(), + playbackRepository: PreviewPlaybackRepository(), + availableSupportModelRepository: PreviewAvailableModelSupportRepository() + ) + } + } + + private struct PreviewVoiceNoteUseCase: VoiceNoteUseCase { + let items: [VoiceNote] + + func create(_ voiceRecord: VoiceRecord) throws(VoiceNoteUseCaseError) -> VoiceNote { + VoiceNote( + title: "미리보기 기록", + folderID: UUID(), + voiceRecord: voiceRecord, + analysisState: .pending + ) + } + + func fetch(byId id: UUID) throws(VoiceNoteUseCaseError) -> VoiceNote { + guard let item = items.first(where: { $0.id == id }) else { throw .recordNotFound(id) } + return item + } + + func update(_ voiceNote: VoiceNote) throws(VoiceNoteUseCaseError) -> VoiceNote { + voiceNote + } + + func observe(id: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream { + guard let item = items.first(where: { $0.id == id }) else { throw .recordNotFound(id) } + return AsyncStream { continuation in + continuation.yield(item) + continuation.finish() + } + } + + func observe(folderID: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let filtered = items.filter { $0.folderID == folderID } + return AsyncStream { continuation in + continuation.yield(filtered) + continuation.finish() + } + } + + func observeRecent(limit: Int) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + let recent = Array(items.prefix(limit)) + return AsyncStream { continuation in + continuation.yield(recent) + continuation.finish() + } + } + + func observeTrashed() throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + AsyncStream { $0.finish() } + } + + func regenerateSummary(id _: UUID) {} + + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + } + + private struct PreviewFolderUseCase: FolderUseCase { + func create(name: String) throws(FolderUseCaseError) -> Folder { + Folder(name: name, kind: .custom) + } + + func createDefault() throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func createTrash() throws(FolderUseCaseError) -> Folder { + Folder(name: "휴지통", kind: .trash) + } + + func fetchAll() throws(FolderUseCaseError) -> [Folder] { + [Folder(name: "기본 폴더", kind: .default)] + } + + func fetchDefault() throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func fetchTrash() throws(FolderUseCaseError) -> Folder { + Folder(name: "휴지통", kind: .trash) + } + + func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] { + [] + } + + func fetch(by _: UUID) throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder { + folder + } + + func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + AsyncStream { continuation in + continuation.yield([]) + continuation.finish() + } + } + + func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + AsyncStream { $0.finish() } + } + + func moveToTrash(folderID _: UUID) throws(FolderUseCaseError) {} + func restore(folderID _: UUID) throws(FolderUseCaseError) {} + func delete(folderID _: UUID) throws(FolderUseCaseError) {} + } + + private struct PreviewPlaybackRepository: VoiceRecordPlaybackRepository { + func prepare(audioFilePath _: String) + throws(VoiceRecordPlaybackRepositoryError) -> AsyncStream + { + AsyncStream { continuation in + continuation.yield(AudioPlaybackState(status: .idle, currentTime: 0, duration: 245)) + continuation.finish() + } + } + + func play() throws(VoiceRecordPlaybackRepositoryError) {} + func pause() throws(VoiceRecordPlaybackRepositoryError) {} + func seek(to _: TimeInterval) throws(VoiceRecordPlaybackRepositoryError) {} + func stop() throws(VoiceRecordPlaybackRepositoryError) {} + } + + private struct PreviewAvailableModelSupportRepository: AvailableModelSupportRepository { + func checkMLXSupportModel() async -> ChaGokModelSupport { + ChaGokModelSupport(ramSizeGB: 8, isProUser: false) + } + + func fetchSupportModels() async -> [ChaGokModelState] { + [] + } + } + +#endif diff --git a/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift new file mode 100644 index 00000000..04032e13 --- /dev/null +++ b/Presentation/Sources/ViewModel/VoiceNote/VoiceNoteViewModel.swift @@ -0,0 +1,518 @@ +import Core +import Domain +import Foundation + +@MainActor +public protocol VoiceNoteCoordinatorDelegate: AnyObject { + func pop() + func presentMoveFolder(for voiceNote: VoiceNote, onComplete: ((String) -> Void)?) +} + +@MainActor +@Observable +public final class VoiceNoteViewModel { + public private(set) var voiceNote: VoiceNote + public private(set) var folderName: String = "" + public private(set) var errorMessage: String? + public private(set) var editingMode: EditingMode? + public private(set) var currentPlaybackState = AudioPlaybackState(status: .idle, currentTime: 0, duration: 0) + public private(set) var playingSectionIndex: Int? + public private(set) var editableScriptSections: [TranscriptSection] = [] + public private(set) var currentPage: Page = .summary + public private(set) var searchMode: Bool = false + public private(set) var searchQuery: String = "" + public private(set) var currentMatchIndex: Int = 0 + public private(set) var isMLXModelSupported: Bool = (ChaGokModelSupport.current.model != .none) + public let isTrashMode: Bool + + @ObservationIgnored + private var playbackObservationTask: Task? + @ObservationIgnored + private var voiceNoteObservationTask: Task? + @ObservationIgnored + private var wasPlayingBeforeSeek = false + public weak var coordinator: VoiceNoteCoordinatorDelegate? + + // MARK: - UseCases + + private let voiceNoteUseCase: any VoiceNoteUseCase + private let folderUseCase: any FolderUseCase + private let playbackRepository: any VoiceRecordPlaybackRepository + private let availableSupportModelRepository: any AvailableModelSupportRepository + + // MARK: - Init + + public init( + voiceNote: VoiceNote, + voiceNoteUseCase: any VoiceNoteUseCase, + folderUseCase: any FolderUseCase, + playbackRepository: any VoiceRecordPlaybackRepository, + availableSupportModelRepository: any AvailableModelSupportRepository, + isTrashMode: Bool = false + ) { + self.voiceNote = voiceNote + self.voiceNoteUseCase = voiceNoteUseCase + self.folderUseCase = folderUseCase + self.playbackRepository = playbackRepository + self.availableSupportModelRepository = availableSupportModelRepository + self.isTrashMode = isTrashMode + } + + // MARK: - View Actions + + public func onAppear() { + setupPlayback() + fetchFolderName() + observeVoiceNote() + checkMLXSupport() + } + + private func checkMLXSupport() { + Task { + let support = await availableSupportModelRepository.checkMLXSupportModel() + self.isMLXModelSupported = (support.model != .none) + } + } + + public func onDisappear() { + playbackObservationTask?.cancel() + playbackObservationTask = nil + voiceNoteObservationTask?.cancel() + voiceNoteObservationTask = nil + stop() + } + + public func playPause() { + if currentPlaybackState.status == .playing { + pause() + } else { + play() + } + } + + public func rewind() { + seek(to: currentPlaybackState.currentTime - Policy.playbackSkipInterval) + } + + public func forward() { + seek(to: currentPlaybackState.currentTime + Policy.playbackSkipInterval) + } + + public func seekBegan() { + wasPlayingBeforeSeek = currentPlaybackState.status == .playing + if wasPlayingBeforeSeek { pause() } + } + + public func seekEnded(_ time: TimeInterval) { + seek(to: time) + if wasPlayingBeforeSeek { + wasPlayingBeforeSeek = false + play() + } + } + + public func scriptTimestampTapped(_ time: TimeInterval) { + seek(to: time) + play() + } + + public func pop() { + coordinator?.pop() + } + + public func moveVoiceNote(onComplete: ((String) -> Void)? = nil) { + guard !isTrashMode else { return } + coordinator?.presentMoveFolder(for: voiceNote, onComplete: onComplete) + } + + public func enterTitleEditing() { + guard !isTrashMode else { return } + editingMode = .title + } + + public func enterScriptEditing() { + guard !isTrashMode else { return } + if currentPlaybackState.status == .playing { pause() } + editableScriptSections = scriptSections + currentPage = .script + editingMode = .script + } + + public func cancelEditing() { + editingMode = nil + } + + public func updateCurrentPage(_ page: Page) { + guard currentPage != page else { return } + currentPage = page + if searchMode { + currentMatchIndex = 0 + } + } + + public func enterSearchMode() { + guard !searchMode else { return } + searchMode = true + searchQuery = "" + currentMatchIndex = 0 + if currentPlaybackState.status == .playing { pause() } + } + + public func exitSearchMode() { + guard searchMode else { return } + searchMode = false + searchQuery = "" + currentMatchIndex = 0 + } + + public func updateSearchQuery(_ query: String) { + searchQuery = query + currentMatchIndex = 0 + } + + public func nextMatch() { + let count = currentPageMatches.count + guard count > 0 else { return } + currentMatchIndex = (currentMatchIndex + 1) % count + } + + public func previousMatch() { + let count = currentPageMatches.count + guard count > 0 else { return } + currentMatchIndex = (currentMatchIndex - 1 + count) % count + } + + public func updateScriptSection(sectionIndex: Int, text: String) { + guard sectionIndex < editableScriptSections.count else { return } + var sections = editableScriptSections + sections[sectionIndex] = TranscriptSection( + timestamp: sections[sectionIndex].timestamp, + text: text + ) + editableScriptSections = sections + } + + public func doneTitleEditing(title: String) { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedTitle.isEmpty, trimmedTitle != voiceNote.title else { + editingMode = nil + return + } + + var updatedNote = voiceNote + updatedNote.title = trimmedTitle + updatedNote.updatedAt = .now + + do { + _ = try voiceNoteUseCase.update(updatedNote) + voiceNote = updatedNote + editingMode = nil + } catch { + errorMessage = "제목 수정에 실패했습니다: \(error.localizedDescription)" + } + } + + public func doneScriptEditing() { + guard let updatedTranscript = makeUpdatedTranscript() else { + editingMode = nil + return + } + + var updatedNote = voiceNote + updatedNote.transcript = updatedTranscript + updatedNote.updatedAt = .now + + do { + voiceNote = try voiceNoteUseCase.update(updatedNote) + editingMode = nil + } catch { + errorMessage = "스크립트 수정에 실패했습니다: \(error.localizedDescription)" + } + } + + private func makeUpdatedTranscript() -> Transcript? { + guard let original = voiceNote.transcript else { return nil } + + let sections = editableScriptSections.filter { + !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + return Transcript( + id: original.id, + createdAt: original.createdAt, + updatedAt: .now, + sections: sections + ) + } + + public func deleteVoiceNote() { + guard !isTrashMode else { return } + moveToWasteBasket() + } + + public func regenerateSummary() { + guard !isTrashMode else { return } + if searchMode { exitSearchMode() } + voiceNoteUseCase.regenerateSummary(id: voiceNote.id) + } + + public func dismissError() { + errorMessage = nil + } + + // MARK: - Private Methods + + private func fetchFolderName() { + do { + folderName = try folderUseCase.fetch(by: voiceNote.folderID).name + } catch { + AppLogger.error(error) + } + } + + private func setupPlayback() { + playbackObservationTask?.cancel() + playbackObservationTask = nil + do { + let stream = try playbackRepository.prepare( + audioFilePath: voiceNote.voiceRecord.audioFilePath + ) + playbackObservationTask = Task { + for await playbackState in stream { + currentPlaybackState = playbackState + updatePlayingParagraph() + } + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func observeVoiceNote() { + voiceNoteObservationTask?.cancel() + voiceNoteObservationTask = Task { + do { + let stream = try voiceNoteUseCase.observe(id: voiceNote.id) + for await note in stream { + let folderChanged = voiceNote.folderID != note.folderID + voiceNote = note + if folderChanged { fetchFolderName() } + } + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func play() { + do { + try playbackRepository.play() + } catch { + errorMessage = error.localizedDescription + } + } + + private func pause() { + do { + try playbackRepository.pause() + } catch { + errorMessage = error.localizedDescription + } + } + + private func seek(to time: TimeInterval) { + do { + try playbackRepository.seek(to: time) + } catch { + errorMessage = error.localizedDescription + } + } + + private func stop() { + do { + try playbackRepository.stop() + } catch { + errorMessage = error.localizedDescription + } + } + + private func moveToWasteBasket() { + do { + stop() + try voiceNoteUseCase.moveToTrash(noteID: voiceNote.id) + coordinator?.pop() + } catch { + errorMessage = error.localizedDescription + } + } + + private func updatePlayingParagraph() { + let currentTime = currentPlaybackState.currentTime + let sections = scriptSections + guard !sections.isEmpty else { + guard playingSectionIndex != nil else { return } + playingSectionIndex = nil + return + } + + var newIndex: Int? + for (index, section) in sections.enumerated().reversed() where section.timestamp <= currentTime { + newIndex = index + break + } + guard playingSectionIndex != newIndex else { return } + playingSectionIndex = newIndex + } +} + +// MARK: - Computed Properties + +public extension VoiceNoteViewModel { + var title: String { + voiceNote.title + } + + var metadataText1: String { + let created = voiceNote.createdAt.toString(format: "yyyy.MM.dd · a HH:mm") + guard voiceNote.createdAt != voiceNote.updatedAt else { return created } + let updated = voiceNote.updatedAt.toString(format: "yyyy.MM.dd") + return "\(created) (\(updated) 수정됨)" + } + + var metadataText2: String { + voiceNote.voiceRecord.duration.koreanDurationString + } + + var keywords: [String] { + voiceNote.keywords.map(\.word).sorted() + } + + var keyPoints: [KeyPoint] { + guard let summary = voiceNote.summary else { return [] } + return summary.text + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .enumerated() + .map { KeyPoint(number: $0.offset + 1, text: $0.element) } + } + + var scriptSections: [TranscriptSection] { + if editingMode == .script { return editableScriptSections } + return voiceNote.transcript?.sections ?? [] + } + + var hasScriptEdits: Bool { + guard editingMode == .script else { return false } + return editableScriptSections != (voiceNote.transcript?.sections ?? []) + } + + var isSummaryOutdated: Bool { + voiceNote.isSummaryOutdated + } + + /// 요약 페이지에서 매치되는 항목 목록. 핵심 포인트 → 키워드 순서로 정렬됩니다. + var summaryMatches: [VoiceNoteSearchMatch] { + guard !searchQuery.isEmpty else { return [] } + var matches: [VoiceNoteSearchMatch] = [] + for (index, point) in keyPoints.enumerated() { + for range in point.text.ranges(of: searchQuery) { + matches.append(.init(location: .keyPoint(index: index), range: range)) + } + } + for (index, keyword) in keywords.enumerated() { + for range in keyword.ranges(of: searchQuery) { + matches.append(.init(location: .keyword(index: index), range: range)) + } + } + return matches + } + + /// 스크립트 페이지에서 매치되는 항목 목록. 섹션 순서 → 섹션 내 위치 순서로 정렬됩니다. + var scriptMatches: [VoiceNoteSearchMatch] { + guard !searchQuery.isEmpty else { return [] } + var matches: [VoiceNoteSearchMatch] = [] + for (index, section) in scriptSections.enumerated() { + for range in section.text.ranges(of: searchQuery) { + matches.append(.init(location: .script(sectionIndex: index), range: range)) + } + } + return matches + } + + /// 현재 페이지에 해당하는 매치 목록. + var currentPageMatches: [VoiceNoteSearchMatch] { + currentPage == .summary ? summaryMatches : scriptMatches + } + + /// 현재 포커스된 매치. + var currentMatch: VoiceNoteSearchMatch? { + let matches = currentPageMatches + guard matches.indices.contains(currentMatchIndex) else { return nil } + return matches[currentMatchIndex] + } + + /// 매치 카운트 표시 문자열 ("현재 / 전체" 포맷, 매치 없으면 "0 / 0"). + var matchCountText: String { + let total = currentPageMatches.count + let display = total > 0 ? currentMatchIndex + 1 : 0 + return "\(display) / \(total)" + } + + /// 현재 페이지에 매치가 하나 이상 있는지 여부. + var hasCurrentPageMatches: Bool { + !currentPageMatches.isEmpty + } + + /// 세그먼트에 표시할 요약 매치 수. 검색 모드가 아니거나 쿼리가 비어 있으면 `nil`을 반환해 카운트를 숨깁니다. + var summaryMatchCount: Int? { + searchMode && !searchQuery.isEmpty ? summaryMatches.count : nil + } + + /// 세그먼트에 표시할 스크립트 매치 수. 검색 모드가 아니거나 쿼리가 비어 있으면 `nil`을 반환해 카운트를 숨깁니다. + var scriptMatchCount: Int? { + searchMode && !searchQuery.isEmpty ? scriptMatches.count : nil + } + + /// 현재 검색 쿼리에 매칭되는 범위를 반환합니다. + func highlightRanges(in text: String) -> [NSRange] { + text.ranges(of: searchQuery) + } + + /// 지정한 핵심 포인트 인덱스가 현재 포커스된 매치이면 해당 범위를 반환합니다. + func focusedKeyPointRange(at index: Int) -> NSRange? { + guard let match = currentMatch, + case .keyPoint(let idx) = match.location, + idx == index else { return nil } + return match.range + } + + /// 현재 포커스된 매치가 키워드이면 (키워드 인덱스, 범위)를 반환합니다. + func focusedKeywordMatch() -> (index: Int, range: NSRange)? { + guard let match = currentMatch, + case .keyword(let idx) = match.location else { return nil } + return (idx, match.range) + } +} + +// MARK: - Nested Types + +public extension VoiceNoteViewModel { + enum EditingMode: Sendable { + case title + case script + } + + enum Page: Int, CaseIterable, Sendable { + case summary + case script + + public var title: String { + switch self { + case .summary: return "요약" + case .script: return "스크립트" + } + } + } +} diff --git a/Presentation/Tests/Folder/FolderDetailViewModelTests.swift b/Presentation/Tests/Folder/FolderDetailViewModelTests.swift new file mode 100644 index 00000000..dd7c6b39 --- /dev/null +++ b/Presentation/Tests/Folder/FolderDetailViewModelTests.swift @@ -0,0 +1,281 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockFolderDetailCoordinatorDelegate: FolderDetailCoordinatorDelegate { + var popCalled = false + var pushedVoiceNote: VoiceNote? + var pushSearchViewCalled = false + var pushedSearchType: SearchViewModel.SearchType? + var pushedSearchItems: [ContentItem] = [] + + var presentFolderListCalled = false + + func pop() { + popCalled = true + } + + func pushVoiceNoteView(voiceNote: Domain.VoiceNote, isTrashMode: Bool) { + pushedVoiceNote = voiceNote + } + + func presentFolderList(with voiceNotes: [VoiceNote], onComplete: ((String) -> Void)?) { + presentFolderListCalled = true + } + + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem]) { + pushSearchViewCalled = true + pushedSearchType = type + pushedSearchItems = items + } +} + +@MainActor +final class FolderDetailViewModelTests: XCTestCase { + // MARK: - SUT + + private struct SUT { + let viewModel: FolderDetailViewModel + let mockVoiceNoteRepo: MockVoiceNoteRepository + let mockFolderRepo: MockFolderRepository + let mockCoordinator: MockFolderDetailCoordinatorDelegate + let testFolderID: UUID + } + + private func makeSUT(title: String = "상세 폴더", folderID: UUID = UUID()) -> SUT { + let mockVoiceNoteRepo = MockVoiceNoteRepository() + let mockFolderRepo = MockFolderRepository() + let mockCoordinator = MockFolderDetailCoordinatorDelegate() + + let viewModel = FolderDetailViewModel( + title: title, + folderID: folderID, + voiceNoteUseCase: DefaultVoiceNoteUseCase( + repository: mockVoiceNoteRepo, + folderRepository: mockFolderRepo, + analysisService: MockVoiceNoteAnalysisService() + ) + ) + viewModel.coordinator = mockCoordinator + + return SUT( + viewModel: viewModel, + mockVoiceNoteRepo: mockVoiceNoteRepo, + mockFolderRepo: mockFolderRepo, + mockCoordinator: mockCoordinator, + testFolderID: folderID + ) + } + + private func makeStream(_ items: [VoiceNote]) -> AsyncStream<[VoiceNote]> { + AsyncStream { continuation in + continuation.yield(items) + continuation.finish() + } + } + + // MARK: - Initial State Tests + + func test_초기상태_확인() { + let folderID = UUID() + let sut = makeSUT(title: "테스트 폴더", folderID: folderID) + + XCTAssertEqual(sut.viewModel.title, "테스트 폴더") + XCTAssertEqual(sut.viewModel.folderID, folderID) + XCTAssertTrue(sut.viewModel.items.isEmpty) + XCTAssertEqual(sut.viewModel.select, .none) + } + + // MARK: - UI Action Tests + + func test_didTapBack_호출시_Pop() { + let sut = makeSUT() + + sut.viewModel.didTapBack() + + XCTAssertTrue(sut.mockCoordinator.popCalled) + } + + func test_pushVoiceNote_호출시_화면전환() { + let sut = makeSUT() + let note = VoiceNote.stub(title: "테스트 노트") + + sut.viewModel.pushVoiceNote(voiceNote: note) + + XCTAssertEqual(sut.mockCoordinator.pushedVoiceNote?.id, note.id) + } + + func test_pushSearch_호출시_화면전환() async { + let sut = makeSUT(title: "상세 폴더") + let note = VoiceNote.stub(title: "검색용 노트") + + sut.mockVoiceNoteRepo.setObserveFolderResult(.success(makeStream([note]))) + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.viewModel.pushSearch() + + XCTAssertTrue(sut.mockCoordinator.pushSearchViewCalled) + if case .myDetailFolder(let title) = sut.mockCoordinator.pushedSearchType { + XCTAssertEqual(title, "상세 폴더") + } else { + XCTFail("Wrong search type") + } + XCTAssertEqual(sut.mockCoordinator.pushedSearchItems.count, 1) + } + + func test_presentMoveFolder_버튼탭시_선택항목존재하면_시트오픈() { + let sut = makeSUT() + let note = VoiceNote.stub(title: "테스트 노트") + + sut.viewModel.selectItem(note) + sut.viewModel.presentMoveFolder { _ in } + + XCTAssertTrue(sut.mockCoordinator.presentFolderListCalled) + } + + func test_presentMoveFolder_버튼탭시_선택항목없으면_무시() { + let sut = makeSUT() + + sut.viewModel.presentMoveFolder { _ in } + + XCTAssertFalse(sut.mockCoordinator.presentFolderListCalled) + } + + func test_deleteButtonTapped_아이템선택시_alertAction호출() { + let sut = makeSUT() + let note = VoiceNote.stub(title: "테스트 노트") + + sut.viewModel.selectItem(note) + + var alertActionCalled = false + sut.viewModel.deleteButtonTapped { + alertActionCalled = true + } + + XCTAssertTrue(alertActionCalled) + } + + func test_deleteButtonTapped_아이템선택없을시_상태원복() { + let sut = makeSUT() + + sut.viewModel.setSelectionMode(.multiple) + XCTAssertEqual(sut.viewModel.select, .multiple) + + var alertActionCalled = false + sut.viewModel.deleteButtonTapped { + alertActionCalled = true + } + + XCTAssertFalse(alertActionCalled) + XCTAssertEqual(sut.viewModel.select, .none) + } + + func test_fetchItems_호출시_보이스노트로드확인() async { + let sut = makeSUT() + let expectedNotes = [ + VoiceNote.stub(title: "노트1"), + VoiceNote.stub(title: "노트2") + ] + + sut.mockVoiceNoteRepo.setObserveFolderResult(.success(makeStream(expectedNotes))) + sut.mockVoiceNoteRepo.expectObserveFolder(callCount: 1, folderID: sut.testFolderID) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockVoiceNoteRepo.verify() + XCTAssertEqual(sut.viewModel.items.count, 2) + + // 정렬 확인 (초기 createdAt 기준 내림차순) + if case .voiceNote(let note1) = sut.viewModel.items[0], + case .voiceNote(let note2) = sut.viewModel.items[1] + { + XCTAssertGreaterThanOrEqual(note1.createdAt, note2.createdAt) + } else { + XCTFail("리스트의 아이템이 VoiceNote 타입이 아닙니다.") + } + } + + func test_선택모드_토글_및_아이템선택() { + let sut = makeSUT() + let voiceNote = VoiceNote.stub(title: "테스트 노트") + + // 선택 모드 켜기 + sut.viewModel.setSelectionMode(.multiple) + XCTAssertEqual(sut.viewModel.select, .multiple) + + // 아이템 선택 + sut.viewModel.selectItem(voiceNote) + XCTAssertEqual(sut.viewModel.selectedItems.count, 1) + XCTAssertEqual(sut.viewModel.selectedItems.first?.id, voiceNote.id) + + // 아이템 해제 + sut.viewModel.deselectItem(voiceNote) + XCTAssertTrue(sut.viewModel.selectedItems.isEmpty) + + // 아이템 선택 후 선택 모드 종료 시 초기화 확인 + sut.viewModel.selectItem(voiceNote) + sut.viewModel.setSelectionMode(.none) + XCTAssertEqual(sut.viewModel.select, .none) + XCTAssertTrue(sut.viewModel.selectedItems.isEmpty) + } + + func test_정렬기준_변경() async { + let sut = makeSUT() + let olderNote = VoiceNote.stub( + id: UUID(), + createdAt: Date().addingTimeInterval(-1000), + updatedAt: Date().addingTimeInterval(-100) + ) + let newerNote = VoiceNote.stub( + id: UUID(), + createdAt: Date(), + updatedAt: Date().addingTimeInterval(-1000) // update 기준으로는 더 이전 + ) + + sut.mockVoiceNoteRepo.setObserveFolderResult(.success(makeStream([olderNote, newerNote]))) + sut.mockVoiceNoteRepo.expectObserveFolder(callCount: 1, folderID: sut.testFolderID) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + // 기본은 생성일 순(createdAt 내림차순) + if case .voiceNote(let topNote) = sut.viewModel.items[0] { + XCTAssertEqual(topNote.id, newerNote.id) + } + + // 수정일 순으로 변경 (updatedAt 내림차순) + sut.viewModel.setOrder(.updatedAt) + if case .voiceNote(let topNote) = sut.viewModel.items[0] { + XCTAssertEqual(topNote.id, olderNote.id) // olderNote의 updatedAt이 최신 + } + } + + func test_move_호출시_아이템제거및_선택모드해제() async { + let sut = makeSUT() + let note = VoiceNote.stub(title: "삭제할 노트") + let trash = Folder.stub(kind: .trash) + + sut.mockVoiceNoteRepo.setObserveFolderResult(.success(makeStream([note]))) + sut.mockVoiceNoteRepo.expectObserveFolder(callCount: 1, folderID: sut.testFolderID) + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.viewModel.selectItem(note) + + sut.mockFolderRepo.setFetchByKindResult(.trash, result: .success([trash])) + sut.mockVoiceNoteRepo.setFetchResult(.success(note)) + sut.mockVoiceNoteRepo.setUpdateResult(.success(note)) + sut.mockVoiceNoteRepo.expectUpdate(callCount: 1) + + sut.viewModel.move() + + sut.mockVoiceNoteRepo.verify() + XCTAssertTrue(sut.viewModel.items.isEmpty) + XCTAssertEqual(sut.viewModel.select, .none) + XCTAssertTrue(sut.viewModel.selectedItems.isEmpty) + } +} diff --git a/Presentation/Tests/Folder/FolderViewModelTests.swift b/Presentation/Tests/Folder/FolderViewModelTests.swift new file mode 100644 index 00000000..b38d2f19 --- /dev/null +++ b/Presentation/Tests/Folder/FolderViewModelTests.swift @@ -0,0 +1,221 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockFolderCoordinatorDelegate: FolderCoordinatorDelegate { + var popCalled = false + var pushedFolder: Folder? + var pushSearchViewCalled = false + var pushedSearchType: SearchViewModel.SearchType? + var pushedSearchItems: [ContentItem] = [] + + func pop() { + popCalled = true + } + + func pushMyFolderDetailView(_ folder: Folder) { + pushedFolder = folder + } + + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem]) { + pushSearchViewCalled = true + pushedSearchType = type + pushedSearchItems = items + } +} + +@MainActor +final class FolderViewModelTests: XCTestCase { + // MARK: - SUT + + private struct SUT { + let viewModel: FolderViewModel + let mockFolderRepo: MockFolderRepository + let mockCoordinator: MockFolderCoordinatorDelegate + } + + private func makeSUT(initialItems: [ContentItem] = []) -> SUT { + let mockFolderRepo = MockFolderRepository() + let mockCoordinator = MockFolderCoordinatorDelegate() + + let initialCategory = CategoryToggle( + imageName: "folder", + title: "개인 폴더", + items: initialItems + ) + + let viewModel = FolderViewModel( + category: initialCategory, + folderUseCase: DefaultFolderUseCase(repository: mockFolderRepo) + ) + viewModel.coordinator = mockCoordinator + + return SUT( + viewModel: viewModel, + mockFolderRepo: mockFolderRepo, + mockCoordinator: mockCoordinator + ) + } + + // MARK: - Initial State Tests + + func test_초기상태_확인() { + let sut = makeSUT() + + XCTAssertEqual(sut.viewModel.category.title, "개인 폴더") + XCTAssertNil(sut.viewModel.editFolder) + } + + // MARK: - UI Action Tests + + func test_didTapBack_호출시_Pop() { + let sut = makeSUT() + + sut.viewModel.didTapBack() + + XCTAssertTrue(sut.mockCoordinator.popCalled) + } + + func test_pushDetail_호출시_화면전환() { + let sut = makeSUT() + let folder = Folder(name: "테스트") + + sut.viewModel.pushDetail(folder) + + XCTAssertEqual(sut.mockCoordinator.pushedFolder?.id, folder.id) + } + + func test_pushSearch_호출시_화면전환() { + let folder = Folder(name: "개인 폴더") + let sut = makeSUT(initialItems: [.folder(folder)]) + + sut.viewModel.pushSearch() + + XCTAssertTrue(sut.mockCoordinator.pushSearchViewCalled) + XCTAssertEqual(sut.mockCoordinator.pushedSearchType, .myFolder) + XCTAssertEqual(sut.mockCoordinator.pushedSearchItems.count, 1) + } + + func test_openTextFieldView_호출시_상태변경() { + let sut = makeSUT() + let folder = Folder(name: "수정 폴더") + + var showFolderAlertCalled = false + var passedField: TextFieldView.Field? + + sut.viewModel.showFolderAlert = { field in + showFolderAlertCalled = true + passedField = field + } + + sut.viewModel.openTextField(for: folder) + + XCTAssertTrue(showFolderAlertCalled) + XCTAssertEqual(passedField?.mode, .edit) + XCTAssertEqual(passedField?.text, "수정 폴더") + XCTAssertEqual(sut.viewModel.editFolder?.name, "수정 폴더") + } + + func test_closeTextFieldView_호출시_상태초기화() { + let sut = makeSUT() + sut.viewModel.openTextField() + + sut.viewModel.closeTextField() + + XCTAssertNil(sut.viewModel.editFolder) + } + + // MARK: - CRUD Tests + + func test_create_성공시_리스트에추가() async { + let sut = makeSUT() + let folderName = "신규 폴더" + let createdFolder = Folder(name: folderName) + + sut.mockFolderRepo.setCreateResult(.success(createdFolder)) + sut.mockFolderRepo.expectCreate(name: folderName, callCount: 1) + + sut.viewModel.create(name: folderName) + + // Task 대기 + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + XCTAssertEqual(sut.viewModel.category.items.count, 1) + } + + func test_fetchAll_정상로드() async { + let sut = makeSUT() + let expectedFolders = [ + Folder(name: "새 폴더 1", kind: .custom), + Folder(name: "기본 폴더", kind: .default), // isDeletable = false는 제외되어야 함 + Folder(name: "새 폴더 2", kind: .custom) + ] + + sut.mockFolderRepo.setFetchAllResult(.success(expectedFolders)) + sut.mockFolderRepo.expectFetchAll(callCount: 1) + + sut.viewModel.fetchAll() + + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + XCTAssertEqual(sut.viewModel.category.items.count, 2) + } + + func test_move_성공시_리스트에서제거() async { + let folder = Folder(name: "이동 폴더") + let trash = Folder.stub(kind: .trash) + let sut = makeSUT(initialItems: [.folder(folder)]) + + sut.mockFolderRepo.setFetchByKindResult(.trash, result: .success([trash])) + sut.mockFolderRepo.setFetchByIDResult(.success(folder)) + sut.mockFolderRepo.setUpdateResult(.success(folder)) + sut.mockFolderRepo.expectUpdate(folderID: folder.id, callCount: 1) + + sut.viewModel.move(folder: folder) + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + XCTAssertTrue(sut.viewModel.category.items.isEmpty) + } + + func test_update_성공시_리스트항목교체() async { + let initialFolder = Folder(id: UUID(), name: "원본 폴더") + let sut = makeSUT(initialItems: [ContentItem.folder(initialFolder)]) + + let newName = "수정된 폴더" + let updatedFolder = Folder( + id: initialFolder.id, + name: newName, + createdAt: initialFolder.createdAt, + voiceNoteIDs: initialFolder.voiceNoteIDs, + kind: initialFolder.kind, + deletedAt: initialFolder.deletedAt + ) + + sut.mockFolderRepo.setUpdateResult(.success(updatedFolder)) + sut.mockFolderRepo.expectUpdate(folderID: initialFolder.id, callCount: 1) + + // 수정 모드 진입 + sut.viewModel.openTextField(for: initialFolder) + + sut.viewModel.update(name: newName) + + // Task 대기 + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + + if case .folder(let folder) = sut.viewModel.category.items[0] { + XCTAssertEqual(folder.name, newName) + XCTAssertEqual(folder.id, initialFolder.id) + } else { + XCTFail("Folder 타입이 아닙니다.") + } + + XCTAssertNil(sut.viewModel.editFolder) + } +} diff --git a/Presentation/Tests/Main/MainViewModelTests.swift b/Presentation/Tests/Main/MainViewModelTests.swift new file mode 100644 index 00000000..a3286193 --- /dev/null +++ b/Presentation/Tests/Main/MainViewModelTests.swift @@ -0,0 +1,334 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockMainCoordinatorDelegate: MainCoordinatorDelegate { + var pushTrashViewCalled = false + var pushMyFolderViewCalled = false + var pushVoiceNoteViewCalled = false + var pushSearchViewCalled = false + var presentRecodingViewCalled = false + var pushSettingViewCalled = false + var popCalled = false + + var pushedCategory: CategoryToggle? + var pushedVoiceNote: VoiceNote? + var pushedSearchType: SearchViewModel.SearchType? + var pushedSearchItems: [ContentItem] = [] + + func pushTrashView() { + pushTrashViewCalled = true + } + + func pushMyFolderView(category: CategoryToggle) { + pushMyFolderViewCalled = true + pushedCategory = category + } + + func pushVoiceNoteView(voiceNote: VoiceNote, isTrashMode: Bool) { + pushVoiceNoteViewCalled = true + pushedVoiceNote = voiceNote + } + + func presentRecodingView() { + presentRecodingViewCalled = true + } + + func pushSearchView(type: Presentation.SearchViewModel.SearchType, items: [Domain.ContentItem]) { + pushSearchViewCalled = true + pushedSearchType = type + pushedSearchItems = items + } + + func pushSettingView() { + pushSettingViewCalled = true + } + + func pop() { + popCalled = true + } +} + +@MainActor +final class MainViewModelTests: XCTestCase { + // MARK: - SUT + + private struct SUT { + let viewModel: MainViewModel + let mockVoiceRecordRepo: MockVoiceRecordRepository + let mockFolderRepo: MockFolderRepository + let mockVoiceNoteRepo: MockVoiceNoteRepository + let mockCoordinator: MockMainCoordinatorDelegate + let mockLanguageRepo: MockLanguageRepository + } + + private func makeStream(_ items: T) -> AsyncStream { + AsyncStream { continuation in + continuation.yield(items) + continuation.finish() + } + } + + private func makeSUT() -> SUT { + let mockVoiceRecordRepo = MockVoiceRecordRepository() + let mockFolderRepo = MockFolderRepository() + let mockVoiceNoteRepo = MockVoiceNoteRepository() + let mockCoordinator = MockMainCoordinatorDelegate() + let mockLanguageRepo = MockLanguageRepository() + + let viewModel = MainViewModel( + microphoneRepository: mockVoiceRecordRepo, + voiceNoteUseCase: DefaultVoiceNoteUseCase( + repository: mockVoiceNoteRepo, + folderRepository: mockFolderRepo, + analysisService: MockVoiceNoteAnalysisService() + ), + folderUseCase: DefaultFolderUseCase(repository: mockFolderRepo) + ) + viewModel.mainCoordinator = mockCoordinator + + return SUT( + viewModel: viewModel, + mockVoiceRecordRepo: mockVoiceRecordRepo, + mockFolderRepo: mockFolderRepo, + mockVoiceNoteRepo: mockVoiceNoteRepo, + mockCoordinator: mockCoordinator, + mockLanguageRepo: mockLanguageRepo + ) + } + + // MARK: - Initial State Tests + + func test_초기상태_확인() { + let sut = makeSUT() + + XCTAssertEqual(sut.viewModel.categoryData.count, 4) + XCTAssertEqual(sut.viewModel.selectedCategoryIndex, 0) + XCTAssertTrue(sut.viewModel.isEmptyList) + } + + // MARK: - Action Tests + + func test_setSelectedCategoryIndex_휴지통선택시_화면전환() { + let sut = makeSUT() + + sut.viewModel.setSelectedCategoryIndex(indexPath: IndexPath(item: 3, section: 0)) + + XCTAssertTrue(sut.mockCoordinator.pushTrashViewCalled) + XCTAssertEqual(sut.viewModel.selectedCategoryIndex, 0) // 다시 0으로 복구되는지 확인 + } + + func test_setSelectedCategoryIndex_폴더목록선택시_화면전환() { + let sut = makeSUT() + + sut.viewModel.setSelectedCategoryIndex(indexPath: IndexPath(item: 2, section: 0)) + + XCTAssertTrue(sut.mockCoordinator.pushMyFolderViewCalled) + XCTAssertNotNil(sut.mockCoordinator.pushedCategory) + XCTAssertEqual(sut.mockCoordinator.pushedCategory?.title, "폴더 목록") + XCTAssertEqual(sut.viewModel.selectedCategoryIndex, 0) + } + + func test_presentRecodingView_호출시_화면전환() { + let sut = makeSUT() + + sut.viewModel.presentRecodingView() + + XCTAssertTrue(sut.mockCoordinator.presentRecodingViewCalled) + } + + func test_pushVoiceNoteView_호출시_화면전환() { + let sut = makeSUT() + let note = VoiceNote.stub(title: "테스트 노트") + + sut.viewModel.pushVoiceNoteView(voiceNote: note) + + XCTAssertTrue(sut.mockCoordinator.pushVoiceNoteViewCalled) + XCTAssertEqual(sut.mockCoordinator.pushedVoiceNote?.id, note.id) + } + + func test_pushSettingView_호출시_화면전환() { + let sut = makeSUT() + sut.viewModel.pushSettingView() + + XCTAssertTrue(sut.mockCoordinator.pushSettingViewCalled) + } + + func test_pushSearchView_호출시_화면전환() async { + // Given + let sut = makeSUT() + let note = VoiceNote.stub(title: "검색용 노트") + let folder = Folder(name: "검색용 폴더", kind: .custom) + + // Mock 데이터 설정 + sut.mockVoiceNoteRepo.setObserveRecentResult(.success(makeStream([note]))) + sut.mockFolderRepo.setObserveByKindResult(.custom, result: .success(makeStream([folder]))) + + // ViewModel 데이터 업데이트 + sut.viewModel.updateRecentCategory() + sut.viewModel.updateMyFolderCategory() + + // 비동기 업데이트 대기 + try? await Task.sleep(nanoseconds: 300_000_000) + + // When + sut.viewModel.pushSearchView() + + // Then + XCTAssertTrue(sut.mockCoordinator.pushSearchViewCalled) + XCTAssertEqual(sut.mockCoordinator.pushedSearchType, .main) + XCTAssertEqual(sut.mockCoordinator.pushedSearchItems.count, 2) + + let hasNote = sut.mockCoordinator.pushedSearchItems.contains { item in + if case .voiceNote(let n) = item { return n.id == note.id } + return false + } + let hasFolder = sut.mockCoordinator.pushedSearchItems.contains { item in + if case .folder(let f) = item { return f.id == folder.id } + return false + } + + XCTAssertTrue(hasNote) + XCTAssertTrue(hasFolder) + } + + func test_didScroll_상태변경() { + let sut = makeSUT() + + sut.viewModel.setDidScroll(true) + XCTAssertTrue(sut.viewModel.didScroll) + + sut.viewModel.setDidScroll(false) + XCTAssertFalse(sut.viewModel.didScroll) + } + + func test_handleRecordButtonTap_권한허용_바로녹음화면이동() async { + let sut = makeSUT() + await sut.mockVoiceRecordRepo.setCheckPermissionResult(.authorized) + await sut.mockVoiceRecordRepo.expectCheckPermission(callCount: 1) + + var alertActionCalled = false + sut.viewModel.handleRecordButtonTap(alertAction: { alertActionCalled = true }) + + await sut.mockVoiceRecordRepo.verify() + XCTAssertTrue(sut.mockCoordinator.presentRecodingViewCalled) + XCTAssertFalse(alertActionCalled) + } + + func test_handleRecordButtonTap_권한거부_알럿노출() async { + let sut = makeSUT() + await sut.mockVoiceRecordRepo.setCheckPermissionResult(.denied) + await sut.mockVoiceRecordRepo.expectCheckPermission(callCount: 1) + + var alertActionCalled = false + sut.viewModel.handleRecordButtonTap(alertAction: { alertActionCalled = true }) + + await sut.mockVoiceRecordRepo.verify() + XCTAssertFalse(sut.mockCoordinator.presentRecodingViewCalled) + XCTAssertTrue(alertActionCalled) + } + + func test_handleRecordButtonTap_권한미결정_알럿노출() async { + let sut = makeSUT() + await sut.mockVoiceRecordRepo.setCheckPermissionResult(.notDetermined) + await sut.mockVoiceRecordRepo.expectCheckPermission(callCount: 1) + + var alertActionCalled = false + sut.viewModel.handleRecordButtonTap(alertAction: { alertActionCalled = true }) + + await sut.mockVoiceRecordRepo.verify() + XCTAssertFalse(sut.mockCoordinator.presentRecodingViewCalled) + XCTAssertTrue(alertActionCalled) + } + + // MARK: - Update Tests + + func test_updateVoiceNoteCategory_호출시_기본폴더보이스노트로드확인() async { + // Given + let sut = makeSUT() + let defaultFolder = Folder.stub(name: "기본 폴더", kind: .default) + let expectedNotes = [VoiceNote.stub(title: "노트1"), VoiceNote.stub(title: "노트2")] + sut.mockFolderRepo.setFetchByKindResult(.default, result: .success([defaultFolder])) + sut.mockVoiceNoteRepo.setObserveFolderResult(.success(makeStream(expectedNotes))) + sut.mockVoiceNoteRepo.expectObserveFolder(callCount: 1, folderID: defaultFolder.id) + + // When + sut.viewModel.updateVoiceNoteCategory() + try? await Task.sleep(nanoseconds: 300_000_000) + + // Then + sut.mockVoiceNoteRepo.verify() + XCTAssertEqual(sut.viewModel.categoryData[1].items.count, 2) + if case .voiceNote(let note) = sut.viewModel.categoryData[1].items[0] { + XCTAssertEqual(note.title, "노트1") + } else { + XCTFail("VoiceNote 타입이 아닙니다.") + } + } + + func test_updateRecentCategory_호출시_최근기록로드확인() async { + // Given + let sut = makeSUT() + let expectedNotes = [VoiceNote.stub(title: "최신1"), VoiceNote.stub(title: "최신2")] + sut.mockVoiceNoteRepo.setObserveRecentResult(.success(makeStream(expectedNotes))) + sut.mockVoiceNoteRepo.expectObserveRecent(callCount: 1) + + // When + sut.viewModel.updateRecentCategory() + try? await Task.sleep(nanoseconds: 300_000_000) + + // Then + sut.mockVoiceNoteRepo.verify() + XCTAssertEqual(sut.viewModel.categoryData[0].items.count, 2) + if case .voiceNote(let note) = sut.viewModel.categoryData[0].items[0] { + XCTAssertEqual(note.title, "최신1") + } else { + XCTFail("VoiceNote 타입이 아닙니다.") + } + } + + func test_updateMyFolderCategory_호출시_데이터로드확인() async { + let sut = makeSUT() + let expectedFolders = [ + Folder(name: "테스트 폴더 1", kind: .custom), + Folder(name: "테스트 폴더 2", kind: .custom) + ] + + sut.mockFolderRepo.setObserveByKindResult(.custom, result: .success(makeStream(expectedFolders))) + + sut.viewModel.updateMyFolderCategory() + + // Task 내부 비동기 대기 + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + XCTAssertEqual(sut.viewModel.categoryData[2].items.count, 2) + + if case .folder(let folder) = sut.viewModel.categoryData[2].items[0] { + XCTAssertEqual(folder.name, "테스트 폴더 1") + } else { + XCTFail("Folder 타입이 아닙니다.") + } + } + + func test_updateTrashCategory_호출시_데이터로드확인() async { + let sut = makeSUT() + let trashedNote = VoiceNote.stub(title: "삭제된 노트") + + sut.mockFolderRepo.setObserveTrashedResult(.success(makeStream([]))) + sut.mockVoiceNoteRepo.setObserveTrashedResult(.success(makeStream([trashedNote]))) + + sut.viewModel.updateTrashCategory() + + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(sut.viewModel.categoryData[3].items.count, 1) + if case .voiceNote(let note) = sut.viewModel.categoryData[3].items[0] { + XCTAssertEqual(note.title, "삭제된 노트") + } else { + XCTFail("VoiceNote 타입이 아닙니다.") + } + } +} diff --git a/Presentation/Tests/OnBoarding/OnBoardingViewModelTests.swift b/Presentation/Tests/OnBoarding/OnBoardingViewModelTests.swift new file mode 100644 index 00000000..67c751f8 --- /dev/null +++ b/Presentation/Tests/OnBoarding/OnBoardingViewModelTests.swift @@ -0,0 +1,245 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockNavigationDelegate: OnboardingCoordinatorDelegate { + var finishOnBoardingCalled = false + var finishOnBoardingExpectation: XCTestExpectation? + + func finishOnBoarding() { + finishOnBoardingCalled = true + finishOnBoardingExpectation?.fulfill() + } +} + +@MainActor +final class OnBoardingViewModelTests: XCTestCase { + // MARK: - SUT + + private struct SUT { + let viewModel: OnBoardingViewModel + let mockLanguageRepo: MockLanguageRepository + let mockVoiceRecordRepo: MockVoiceRecordRepository + let mockSTTRepo: MockSTTRepository + let mockCheckFirstLaunchRepo: MockCheckFirstLaunchRepository + let mockFolderRepo: MockFolderRepository + let mockNavDelegate: MockNavigationDelegate + let mockAvailableModelRepo: MockAvailableModelSupportRepository + let mockMLXRepo: MockOnDeviceRepository + } + + private func makeSUT() -> SUT { + let mockLanguageRepo = MockLanguageRepository() + let mockVoiceRecordRepo = MockVoiceRecordRepository() + let mockSTTRepo = MockSTTRepository() + let mockCheckFirstLaunchRepo = MockCheckFirstLaunchRepository() + let mockFolderRepo = MockFolderRepository() + let mockNavDelegate = MockNavigationDelegate() + let mockAvailableModelRepo = MockAvailableModelSupportRepository() + let mockMLXRepo = MockOnDeviceRepository() + + let viewModel = OnBoardingViewModel( + languageRepository: mockLanguageRepo, + voiceRecordRepository: mockVoiceRecordRepo, + sttRepository: mockSTTRepo, + checkFirstLaunchRepository: mockCheckFirstLaunchRepo, + folderUseCase: DefaultFolderUseCase(repository: mockFolderRepo), + availableSupportModelRepository: mockAvailableModelRepo, + mlxRepository: mockMLXRepo + ) + viewModel.onBoardingCoordinator = mockNavDelegate + + return SUT( + viewModel: viewModel, + mockLanguageRepo: mockLanguageRepo, + mockVoiceRecordRepo: mockVoiceRecordRepo, + mockSTTRepo: mockSTTRepo, + mockCheckFirstLaunchRepo: mockCheckFirstLaunchRepo, + mockFolderRepo: mockFolderRepo, + mockNavDelegate: mockNavDelegate, + mockAvailableModelRepo: mockAvailableModelRepo, + mockMLXRepo: mockMLXRepo + ) + } + + // MARK: - State Tests + + func test_뷰모델생성시_초기화된경우_초기값을확인한다() { + let sut = makeSUT() + + XCTAssertEqual(sut.viewModel.currentStep, .first) + XCTAssertEqual(sut.viewModel.steps.count, 5) + XCTAssertEqual(sut.viewModel.primaryButtonTitle, "다음") + XCTAssertEqual(sut.viewModel.secondButtonTitle, "건너뛰기") + XCTAssertTrue(sut.viewModel.isSecondButtonEnabled) + XCTAssertEqual(sut.viewModel.language, .ko) + } + + func test_언어설정시_상태가_업데이트된다() { + let sut = makeSUT() + + sut.viewModel.setLanguage(.en) + XCTAssertEqual(sut.viewModel.language, .en) + } + + func test_마지막스텝인경우_버튼타이틀과_상태가_변경된다() { + let sut = makeSUT() + + sut.viewModel.syncPageState(nextStep: Step.finish.rawValue) // 4 + + XCTAssertEqual(sut.viewModel.currentStep, .finish) + XCTAssertEqual(sut.viewModel.primaryButtonTitle, "시작하기") + XCTAssertEqual(sut.viewModel.secondButtonTitle, "") + XCTAssertFalse(sut.viewModel.isSecondButtonEnabled) + } + + // MARK: - Action Tests + + func test_syncPageState호출시_마이크권한스텝이면_권한을_요청한다() async { + let sut = makeSUT() + + // Mic + await sut.mockVoiceRecordRepo.setCheckPermissionResult(.notDetermined) + await sut.mockVoiceRecordRepo.setRequestPermissionResult(.success(.authorized)) + // STT + await sut.mockSTTRepo.setCheckResult(.notDetermined) + await sut.mockSTTRepo.setRequestResult(.success(.authorized)) + + sut.viewModel.syncPageState(nextStep: Step.micPermission.rawValue) + + // Task 내부 비동기 호출 대기 (안전하게 0.3초 대기) + try? await Task.sleep(nanoseconds: 300_000_000) + + // Mic 검증 + await sut.mockVoiceRecordRepo.expectCheckPermission(callCount: 1) + await sut.mockVoiceRecordRepo.expectRequestPermission(callCount: 1) + await sut.mockVoiceRecordRepo.verify() + + // STT 검증 + await sut.mockSTTRepo.expectCheckSTTPermission(callCount: 1) + await sut.mockSTTRepo.expectRequestSTTPermission(callCount: 1) + await sut.mockSTTRepo.verify() + } + + func test_primaryButtonAction_첫스텝에서_다음스텝으로_이동한다() { + let sut = makeSUT() + + var scrolledIndex: Int? + sut.viewModel.primaryButtonAction { nextIndex in + scrolledIndex = nextIndex + } + + XCTAssertEqual(scrolledIndex, Step.second.rawValue) // 1 + } + + func test_primaryButtonAction_마지막스텝에서_온보딩을_완료하고_화면을_전환한다() async { + let sut = makeSUT() + + sut.viewModel.syncPageState(nextStep: Step.finish.rawValue) + + sut.mockCheckFirstLaunchRepo.setReturnValue(true) + sut.mockFolderRepo.setCreateResult(.success(Folder(name: Policy.defaultFolderName, kind: .default))) + // 기본 폴더 + 휴지통 폴더 두 번 생성됨 + sut.mockFolderRepo.expectCreate(callCount: 2) + + let expectation = XCTestExpectation(description: "finishOnBoarding 호출") + sut.mockNavDelegate.finishOnBoardingExpectation = expectation + + sut.viewModel.primaryButtonAction { _ in } + + await fulfillment(of: [expectation], timeout: 1.0) + + XCTAssertTrue(sut.mockNavDelegate.finishOnBoardingCalled) + + // 언어 저장 확인 + sut.mockLanguageRepo.expectSave(language: .ko, callCount: 1) + sut.mockLanguageRepo.verify() + + // 첫 실행 마킹 확인 + sut.mockCheckFirstLaunchRepo.expectCheckAndMarkFirstLaunch(callCount: 1) + sut.mockCheckFirstLaunchRepo.verify() + + // 기본 폴더 생성 확인 + sut.mockFolderRepo.verify() + } + + func test_secondButtonAction_첫스텝에서_건너뛰기를_누르면_마이크권한화면으로_이동한다() { + let sut = makeSUT() + + var scrolledIndex: Int? + sut.viewModel.secondButtonAction { nextIndex in + scrolledIndex = nextIndex + } + + XCTAssertEqual(scrolledIndex, Step.micPermission.rawValue) + } + + func test_secondButtonAction_중간스텝에서_이전버튼을_누르면_이전스텝으로_이동한다() async { + let sut = makeSUT() + + // Background Task가 실행되므로 미리 모의 객체(Mock) 응답을 세팅해 두어야 에러(미설정)가 나지 않습니다. + // Mic + await sut.mockVoiceRecordRepo.setCheckPermissionResult(.notDetermined) + await sut.mockVoiceRecordRepo.setRequestPermissionResult(.success(.authorized)) + // STT + await sut.mockSTTRepo.setCheckResult(.notDetermined) + await sut.mockSTTRepo.setRequestResult(.success(.authorized)) + + sut.viewModel.syncPageState(nextStep: Step.micPermission.rawValue) + + // 백그라운드 Task가 안전하게 완료될 수 있도록 약간의 딜레이 부여 + try? await Task.sleep(nanoseconds: 300_000_000) + + var scrolledIndex: Int? + sut.viewModel.secondButtonAction { nextIndex in + scrolledIndex = nextIndex + } + + XCTAssertEqual(scrolledIndex, Step.second.rawValue) + } + + func test_checkModelSupport호출시_지원하는기기이면_modelSupport가true가된다() async { + let sut = makeSUT() + + sut.mockAvailableModelRepo.setCheckSupportModelResult(ChaGokModelSupport(ramSizeGB: 8, isProUser: false)) + sut.mockAvailableModelRepo.expectCheckSupportModel(callCount: 1) + + await sut.viewModel.checkModelSupport() + + sut.mockAvailableModelRepo.verify() + XCTAssertTrue(sut.viewModel.modelSupport) + XCTAssertEqual(sut.viewModel.status.storage, .notDownloaded) + } + + func test_primaryButtonAction_다운로드스텝에서_성공적으로_다운로드하면_상태가_downloaded가된다() async { + let sut = makeSUT() + + sut.mockAvailableModelRepo.setCheckSupportModelResult(ChaGokModelSupport(ramSizeGB: 8, isProUser: false)) + sut.mockAvailableModelRepo.expectCheckSupportModel(callCount: 1) + sut.mockMLXRepo.downloadResult = .success(()) + + await sut.viewModel.checkModelSupport() + sut.viewModel.syncPageState(nextStep: Step.download.rawValue) + + sut.viewModel.primaryButtonAction { _ in } + try? await Task.sleep(nanoseconds: 300_000_000) // 다운로드 완료 비동기 대기 + + sut.mockAvailableModelRepo.verify() + XCTAssertEqual(sut.mockMLXRepo.actualDownloadCallCount, 1) + XCTAssertEqual(sut.viewModel.status.storage, .downloaded) + } + + func test_checkModelSupport호출시_RAM이4GB이하로부족하면_modelSupport가false가된다() async { + let sut = makeSUT() + + sut.mockAvailableModelRepo.setCheckSupportModelResult(ChaGokModelSupport(ramSizeGB: 4, isProUser: false)) + sut.mockAvailableModelRepo.expectCheckSupportModel(callCount: 1) + + await sut.viewModel.checkModelSupport() + + sut.mockAvailableModelRepo.verify() + XCTAssertFalse(sut.viewModel.modelSupport) + } +} diff --git a/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift b/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift new file mode 100644 index 00000000..09ff1b30 --- /dev/null +++ b/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift @@ -0,0 +1,139 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockDownloadOnDeviceCoordinator: DownloadOnDeviceCoordinatorDelegate { + private(set) var dismissSheetCallCount = 0 + + func dismissSheet() { + dismissSheetCallCount += 1 + } +} + +final class DownloadMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked Sendable { + private(set) var downloadCallCount = 0 + private(set) var deleteCallCount = 0 + private(set) var lastDownloadedModel: ChaGokModel? + private(set) var lastDeletedModel: ChaGokModel? + + func subscribe(model: ChaGokModel) -> AsyncStream { + AsyncStream { cont in + cont.yield(OnDeviceStatus(storage: .notDownloaded)) + cont.finish() + } + } + + func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) { + downloadCallCount += 1 + lastDownloadedModel = model + } + + func cancelDownload(model: ChaGokModel) { + // Not used by the VM directly (VM uses downloadTask cancellation + delete) + } + + func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) { + deleteCallCount += 1 + lastDeletedModel = model + } + + func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { + return OnDeviceStatus(storage: .notDownloaded) + } +} + +@MainActor +final class DownloadOnDeviceViewModelTests: XCTestCase { + private struct SUT { + let viewModel: DownloadOnDeviceViewModel + let useCase: DownloadMockOnDeviceStatusUseCase + let coordinator: MockDownloadOnDeviceCoordinator + } + + private func makeSUT() -> SUT { + let useCase = DownloadMockOnDeviceStatusUseCase() + let coordinator = MockDownloadOnDeviceCoordinator() + let viewModel = DownloadOnDeviceViewModel(onDeviceStatusUseCase: useCase) + viewModel.coordinator = coordinator + + return SUT( + viewModel: viewModel, + useCase: useCase, + coordinator: coordinator + ) + } +} + +// MARK: - 초기 상태 및 기본 호출 검사 + +extension DownloadOnDeviceViewModelTests { + func test_뷰모델생성시_초기상태를확인한다() { + // Given & When + let sut = makeSUT() + + // Then + XCTAssertFalse(sut.viewModel.isDownloading) + XCTAssertNil(sut.viewModel.errorMessage) + XCTAssertEqual(sut.viewModel.status.storage, .notDownloaded) + } + + func test_download호출시_유즈케이스의download를호출한다() async { + // Given + let sut = makeSUT() + + // When + sut.viewModel.download() + + // 비동기 Task 내부에서 유즈케이스 메소드가 호출될 때까지 대기 + let start = Date() + while sut.useCase.downloadCallCount == 0 { + if Date().timeIntervalSince(start) > 1.0 { + XCTFail("Timeout waiting for download call") + return + } + await Task.yield() + } + + // Then + XCTAssertEqual(sut.useCase.downloadCallCount, 1) + XCTAssertEqual(sut.useCase.lastDownloadedModel, .whisper) + } + + func test_cancelDownload호출시_상태를초기화하고_유즈케이스의delete를호출한다() async { + // Given + let sut = makeSUT() + + // When + sut.viewModel.cancelDownload() + + // Then + XCTAssertFalse(sut.viewModel.isDownloading) + XCTAssertEqual(sut.viewModel.status.storage, .notDownloaded) + + // 비동기 Task 내부에서 유즈케이스 메소드가 호출될 때까지 대기 + let start = Date() + while sut.useCase.deleteCallCount == 0 { + if Date().timeIntervalSince(start) > 1.0 { + XCTFail("Timeout waiting for delete call") + return + } + await Task.yield() + } + + XCTAssertEqual(sut.useCase.deleteCallCount, 1) + XCTAssertEqual(sut.useCase.lastDeletedModel, .whisper) + } + + func test_dismiss호출시_coordinator의dismissSheet를호출한다() { + // Given + let sut = makeSUT() + + // When + sut.viewModel.dismiss() + + // Then + XCTAssertEqual(sut.coordinator.dismissSheetCallCount, 1) + } +} diff --git a/Presentation/Tests/Recording/RecordingViewModelTests.swift b/Presentation/Tests/Recording/RecordingViewModelTests.swift new file mode 100644 index 00000000..f90cf736 --- /dev/null +++ b/Presentation/Tests/Recording/RecordingViewModelTests.swift @@ -0,0 +1,360 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockRecordingCoordinator: RecordingCoordinating { + private(set) var cancelRecordingCallCount = 0 + private(set) var finishRecordingCallCount = 0 + private(set) var finishedVoiceNote: VoiceNote? + + func cancelRecording() { + cancelRecordingCallCount += 1 + } + + func finishRecording(voiceNote: VoiceNote) { + finishRecordingCallCount += 1 + finishedVoiceNote = voiceNote + } +} + +@MainActor +final class RecordingViewModelTests: XCTestCase { + private struct SUT { + let viewModel: RecordingViewModel + let repository: MockVoiceRecordRepository + let voiceNoteRepository: MockVoiceNoteRepository + let folderRepository: MockFolderRepository + let coordinator: MockRecordingCoordinator + } + + private func makeSUT() -> SUT { + let repository = MockVoiceRecordRepository() + let voiceNoteRepository = MockVoiceNoteRepository() + let folderRepository = MockFolderRepository() + let coordinator = MockRecordingCoordinator() + + let viewModel = RecordingViewModel( + repository: repository, + voiceNoteUseCase: DefaultVoiceNoteUseCase( + repository: voiceNoteRepository, + folderRepository: folderRepository, + analysisService: MockVoiceNoteAnalysisService() + ) + ) + viewModel.coordinator = coordinator + + return SUT( + viewModel: viewModel, + repository: repository, + voiceNoteRepository: voiceNoteRepository, + folderRepository: folderRepository, + coordinator: coordinator + ) + } +} + +// MARK: - 초기 상태 + +extension RecordingViewModelTests { + func test_뷰모델생성시_초기상태를확인한다() { + // Given & When + let sut = makeSUT() + + // Then + XCTAssertEqual(sut.viewModel.state.recordingState, .idle) + XCTAssertEqual(sut.viewModel.state.amplitude, 0) + XCTAssertNil(sut.viewModel.state.errorMessage) + XCTAssertEqual(sut.viewModel.state.recordingDuration, 0) + } +} + +// MARK: - 녹음 시작 + +extension RecordingViewModelTests { + func test_idle상태_viewDidAppear_녹음을자동시작하고recording상태가된다() async { + // Given + let sut = makeSUT() + let stream = AsyncStream { $0.finish() } + await sut.repository.setStartResult(.success(stream)) + await sut.repository.expectStartRecording(callCount: 1) + + // When + sut.viewModel.send(.viewDidAppear) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.viewModel.state.recordingState, .recording) + await sut.repository.verify() + } + + func test_recording상태_viewDidAppear_추가녹음을시작하지않는다() async { + // Given + let sut = makeSUT() + let stream = AsyncStream { $0.finish() } + await sut.repository.setStartResult(.success(stream)) + await sut.repository.expectStartRecording(callCount: 1) + + // When + sut.viewModel.send(.viewDidAppear) + try? await Task.sleep(nanoseconds: 100_000_000) + sut.viewModel.send(.viewDidAppear) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.viewModel.state.recordingState, .recording) + await sut.repository.verify() + } + + func test_idle상태_recordButtonTapped_녹음을시작하고recording상태가된다() async { + // Given + let sut = makeSUT() + let stream = AsyncStream { $0.finish() } + await sut.repository.setStartResult(.success(stream)) + + // When + sut.viewModel.send(.recordButtonTapped) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.viewModel.state.recordingState, .recording) + XCTAssertNil(sut.viewModel.state.errorMessage) + } + + func test_idle상태_녹음시작실패시_idle상태를유지하고errorMessage를설정한다() async { + // Given + let sut = makeSUT() + await sut.repository.setStartResult(.failure(.startFailed)) + + // When + sut.viewModel.send(.recordButtonTapped) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.viewModel.state.recordingState, .idle) + XCTAssertNotNil(sut.viewModel.state.errorMessage) + } +} + +// MARK: - 녹음 일시정지 + +extension RecordingViewModelTests { + func test_recording상태_recordButtonTapped_녹음을일시정지하고paused상태가된다() async { + // Given + let sut = makeSUT() + let stream = AsyncStream { $0.finish() } + await sut.repository.setStartResult(.success(stream)) + await sut.repository.setPauseResult(.success(())) + + sut.viewModel.send(.recordButtonTapped) // idle → recording + try? await Task.sleep(nanoseconds: 100_000_000) + + // When + sut.viewModel.send(.recordButtonTapped) // recording → paused + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.viewModel.state.recordingState, .paused) + } + + func test_recording상태_일시정지실패시_errorMessage를설정한다() async { + // Given + let sut = makeSUT() + let stream = AsyncStream { $0.finish() } + await sut.repository.setStartResult(.success(stream)) + await sut.repository.setPauseResult(.failure(.pauseFailed)) + + sut.viewModel.send(.recordButtonTapped) // idle → recording + try? await Task.sleep(nanoseconds: 100_000_000) + + // When + sut.viewModel.send(.recordButtonTapped) // recording → pause 실패 + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertNotNil(sut.viewModel.state.errorMessage) + } +} + +// MARK: - 녹음 재개 + +extension RecordingViewModelTests { + func test_paused상태_recordButtonTapped_녹음을재개하고recording상태가된다() async { + // Given + let sut = makeSUT() + let stream = AsyncStream { $0.finish() } + await sut.repository.setStartResult(.success(stream)) + await sut.repository.setPauseResult(.success(())) + await sut.repository.setResumeResult(.success(())) + + sut.viewModel.send(.recordButtonTapped) // idle → recording + try? await Task.sleep(nanoseconds: 100_000_000) + sut.viewModel.send(.recordButtonTapped) // recording → paused + try? await Task.sleep(nanoseconds: 100_000_000) + + // When + sut.viewModel.send(.recordButtonTapped) // paused → recording + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.viewModel.state.recordingState, .recording) + } + + func test_paused상태_녹음재개실패시_errorMessage를설정한다() async { + // Given + let sut = makeSUT() + let stream = AsyncStream { $0.finish() } + await sut.repository.setStartResult(.success(stream)) + await sut.repository.setPauseResult(.success(())) + await sut.repository.setResumeResult(.failure(.resumeFailed)) + + sut.viewModel.send(.recordButtonTapped) // idle → recording + try? await Task.sleep(nanoseconds: 100_000_000) + sut.viewModel.send(.recordButtonTapped) // recording → paused + try? await Task.sleep(nanoseconds: 100_000_000) + + // When + sut.viewModel.send(.recordButtonTapped) // paused → resume 실패 + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertNotNil(sut.viewModel.state.errorMessage) + } +} + +// MARK: - 취소 + +extension RecordingViewModelTests { + func test_cancelButtonTapped_녹음을중단하고coordinator의cancelRecording을호출한다() async { + // Given + let sut = makeSUT() + await sut.repository.setCancelResult(.success(())) + await sut.repository.expectCancelRecording(callCount: 1) + + // When + sut.viewModel.send(.cancelButtonTapped) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.coordinator.cancelRecordingCallCount, 1) + await sut.repository.verify() + } + + func test_openCancelAlertButtonTapped_duration이_3초이하면_cancelButtonTapped가_호출된다() async { + // Given + let sut = makeSUT() + sut.viewModel.state.recordingDuration = 3 + await sut.repository.setCancelResult(.success(())) + await sut.repository.expectCancelRecording(callCount: 1) + + // When + sut.viewModel.send(.openCancelAlertButtonTapped) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.coordinator.cancelRecordingCallCount, 1) + await sut.repository.verify() + } + + func test_openCancelAlertButtonTapped_duration이_3초초과면_showCancelAlert가_호출된다() { + // Given + let sut = makeSUT() + sut.viewModel.state.recordingDuration = 4 + var showCancelAlertCalled = false + sut.viewModel.showCancelAlert = { + showCancelAlertCalled = true + } + + // When + sut.viewModel.send(.openCancelAlertButtonTapped) + + // Then + XCTAssertTrue(showCancelAlertCalled) + } +} + +// MARK: - 완료 + +extension RecordingViewModelTests { + func test_openCompleteAlertButtonTapped_showCompleteAlert가_호출된다() { + // Given + let sut = makeSUT() + var showCompleteAlertCalled = false + sut.viewModel.showCompleteAlert = { + showCompleteAlertCalled = true + } + + // When + sut.viewModel.send(.openCompleteAlertButtonTapped) + + // Then + XCTAssertTrue(showCompleteAlertCalled) + } +} + +// MARK: - 에러 처리 + +extension RecordingViewModelTests { + func test_errorOccurred_errorMessage를설정한다() { + // Given + let sut = makeSUT() + let expectedError = VoiceRecordRepositoryError.startFailed + + // When + sut.viewModel.send(.errorOccurred(expectedError)) + + // Then + XCTAssertEqual(sut.viewModel.state.errorMessage, expectedError.localizedDescription) + } + + func test_finishButtonTapped_녹음완료후보이스노트를생성하고coordinator의finishRecording을호출한다() async { + // Given + let sut = makeSUT() + let voiceRecordStub = VoiceRecord.stub() + let voiceNoteStub = VoiceNote.stub(voiceRecord: voiceRecordStub) + let defaultFolder = Folder.stub(name: "기본 폴더", kind: .default) + await sut.repository.setFinishResult(.success(voiceRecordStub)) + sut.folderRepository.setFetchByKindResult(.default, result: .success([defaultFolder])) + sut.voiceNoteRepository.setCreateResult(.success(voiceNoteStub)) + + // When + sut.viewModel.send(.finishButtonTapped) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.coordinator.finishRecordingCallCount, 1) + XCTAssertEqual(sut.coordinator.finishedVoiceNote?.id, voiceNoteStub.id) + } + + func test_finishButtonTapped_녹음완료실패시_coordinator를호출하지않고errorMessage를설정한다() async { + // Given + let sut = makeSUT() + await sut.repository.setFinishResult(.failure(.finishFailed)) + + // When + sut.viewModel.send(.finishButtonTapped) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.coordinator.finishRecordingCallCount, 0) + XCTAssertNotNil(sut.viewModel.state.errorMessage) + } + + func test_finishButtonTapped_보이스노트생성실패시_coordinator를호출하지않고errorMessage를설정한다() async { + // Given + let sut = makeSUT() + let defaultFolder = Folder.stub(name: "기본 폴더", kind: .default) + await sut.repository.setFinishResult(.success(.stub())) + sut.folderRepository.setFetchByKindResult(.default, result: .success([defaultFolder])) + sut.voiceNoteRepository.setCreateResult(.failure(.createFailed)) + + // When + sut.viewModel.send(.finishButtonTapped) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + XCTAssertEqual(sut.coordinator.finishRecordingCallCount, 0) + XCTAssertNotNil(sut.viewModel.state.errorMessage) + } +} diff --git a/Presentation/Tests/Setting/SettingViewModelTests.swift b/Presentation/Tests/Setting/SettingViewModelTests.swift new file mode 100644 index 00000000..8817ffae --- /dev/null +++ b/Presentation/Tests/Setting/SettingViewModelTests.swift @@ -0,0 +1,364 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockSettingCoordinatorDelegate: SettingCoordinatorDelegate { + var popCalled = false + var pushTermsCalled = false + var pushPrivacyCalled = false + + func pop() { + popCalled = true + } + + func pushTermsOfUseView() { + pushTermsCalled = true + } + + func pushPrivacyPolicyView() { + pushPrivacyCalled = true + } +} + +final class SettingMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked Sendable { + private var continuation: AsyncStream.Continuation? + private(set) var downloadCallCount = 0 + private(set) var deleteCallCount = 0 + private(set) var lastDownloadedModel: ChaGokModel? + private(set) var lastDeletedModel: ChaGokModel? + + var downloadResult: Result = .success(()) + var deleteResult: Result = .success(()) + + func subscribe(model: ChaGokModel) -> AsyncStream { + AsyncStream { cont in + self.continuation = cont + cont.yield(OnDeviceStatus(storage: .notDownloaded)) + } + } + + func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) { + downloadCallCount += 1 + lastDownloadedModel = model + switch downloadResult { + case .success: + emit(status: OnDeviceStatus(storage: .downloaded)) + case .failure(let error): + emit(status: OnDeviceStatus(storage: .failed)) + throw error + } + } + + func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) { + deleteCallCount += 1 + lastDeletedModel = model + switch deleteResult { + case .success: + emit(status: OnDeviceStatus(storage: .notDownloaded)) + case .failure(let error): + throw error + } + } + + func emit(status: OnDeviceStatus) { + continuation?.yield(status) + } + + var checkStatusResult: OnDeviceStatus = OnDeviceStatus(storage: .notDownloaded) + + func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { + return checkStatusResult + } +} + +@MainActor +final class SettingViewModelTests: XCTestCase { + // MARK: - SUT + + private struct SUT { + let viewModel: SettingViewModel + let mockLanguageRepo: MockLanguageRepository + let mockAvailableModelRepo: MockAvailableModelSupportRepository + let mockOnDeviceStatusUseCase: SettingMockOnDeviceStatusUseCase + let mockCoordinator: MockSettingCoordinatorDelegate + } + + private func makeSUT() -> SUT { + let mockLanguageRepo = MockLanguageRepository() + let mockAvailableModelRepo = MockAvailableModelSupportRepository() + let mockOnDeviceStatusUseCase = SettingMockOnDeviceStatusUseCase() + let mockCoordinator = MockSettingCoordinatorDelegate() + + let viewModel = SettingViewModel( + languageRepository: mockLanguageRepo, + availableModelRepository: mockAvailableModelRepo, + onDeviceStatusUseCase: mockOnDeviceStatusUseCase + ) + viewModel.coordinator = mockCoordinator + + return SUT( + viewModel: viewModel, + mockLanguageRepo: mockLanguageRepo, + mockAvailableModelRepo: mockAvailableModelRepo, + mockOnDeviceStatusUseCase: mockOnDeviceStatusUseCase, + mockCoordinator: mockCoordinator + ) + } + + // MARK: - Initial State Tests + + func test_초기상태_언어_확인() { + // Arrange + let sut = makeSUT() + sut.mockLanguageRepo.setFetchResult(.ko) + + // Act + let viewModel = SettingViewModel( + languageRepository: sut.mockLanguageRepo, + availableModelRepository: sut.mockAvailableModelRepo, + onDeviceStatusUseCase: sut.mockOnDeviceStatusUseCase + ) + + // Assert + XCTAssertEqual(viewModel.language, .ko) + } + + func test_초기상태_모델_비어있음() { + // Arrange + let sut = makeSUT() + + // Assert + XCTAssertEqual(sut.viewModel.models.count, 0) + } + + // MARK: - Language Tests + + func test_언어변경_성공() { + // Arrange + let sut = makeSUT() + + // Act + sut.viewModel.setLanguage(.en) + + // Assert + XCTAssertEqual(sut.viewModel.language, .en) + } + + func test_언어변경_저장소에_저장됨() { + // Arrange + let sut = makeSUT() + sut.mockLanguageRepo.expectSave(language: .en, callCount: 1) + + // Act + sut.viewModel.setLanguage(.en) + + // Assert + sut.mockLanguageRepo.verify() + } + + // MARK: - Check Models Tests + + func test_checkModels_모델가져오기_성공() async { + // Arrange + let sut = makeSUT() + let mockModels = [ + ChaGokModelState( + title: "whisper title", + subTitle: "whisper subTitle", + model: .whisper, + status: OnDeviceStatus(storage: .downloaded) + ), + ChaGokModelState( + title: "gemma4 title", + subTitle: "gemma4 subTitle", + model: .gemma4_e2b_4bit, + status: OnDeviceStatus(storage: .notDownloaded) + ) + ] + sut.mockAvailableModelRepo.setFetchSupportModelsResult(mockModels) + + // Act + sut.viewModel.checkModels() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + XCTAssertEqual(sut.viewModel.models.count, 2) + XCTAssertEqual(sut.viewModel.models[0].model, .whisper) + XCTAssertEqual(sut.viewModel.models[1].model, .gemma4_e2b_4bit) + } + + func test_checkModels_fetchSupportModels_호출됨() async { + // Arrange + let sut = makeSUT() + sut.mockAvailableModelRepo.setFetchSupportModelsResult([]) + sut.mockAvailableModelRepo.expectFetchSupportModels(callCount: 1) + + // Act + sut.viewModel.checkModels() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + sut.mockAvailableModelRepo.verify() + } + + // MARK: - Download Model Tests + + func test_downloadModel_whisper_완료() async { + // Arrange + let sut = makeSUT() + let model = ChaGokModel.whisper + sut.mockAvailableModelRepo.setFetchSupportModelsResult([ + ChaGokModelState( + title: "whisper title", + subTitle: "whisper subTitle", + model: .whisper, + status: OnDeviceStatus(storage: .notDownloaded) + ) + ]) + + // Act + sut.viewModel.checkModels() + try? await Task.sleep(nanoseconds: 100_000_000) + sut.viewModel.downloadModel(model: model) + try? await Task.sleep(nanoseconds: 500_000_000) + + // Assert + XCTAssertEqual(sut.viewModel.models[0].status.storage, .downloaded) + } + + func test_downloadModel_gemma_완료() async { + // Arrange + let sut = makeSUT() + let model = ChaGokModel.gemma4_e2b_4bit + sut.mockAvailableModelRepo.setFetchSupportModelsResult([ + ChaGokModelState( + title: "gemma4 title", + subTitle: "gemma4 subTitle", + model: .gemma4_e2b_4bit, + status: OnDeviceStatus(storage: .notDownloaded) + ) + ]) + + // Act + sut.viewModel.checkModels() + try? await Task.sleep(nanoseconds: 100_000_000) + sut.viewModel.downloadModel(model: model) + try? await Task.sleep(nanoseconds: 500_000_000) + + // Assert + XCTAssertEqual(sut.viewModel.models[0].status.storage, .downloaded) + } + + // MARK: - Delete Model Tests + + func test_deleteModel_whisper_완료() async { + // Arrange + let sut = makeSUT() + let model = ChaGokModel.whisper + sut.mockAvailableModelRepo.setFetchSupportModelsResult([ + ChaGokModelState( + title: "whisper title", + subTitle: "whisper subTitle", + model: .whisper, + status: OnDeviceStatus(storage: .downloaded) + ) + ]) + + // Act + sut.viewModel.checkModels() + try? await Task.sleep(nanoseconds: 100_000_000) + sut.viewModel.deleteModel(model: model) + try? await Task.sleep(nanoseconds: 500_000_000) + + // Assert + XCTAssertEqual(sut.viewModel.models[0].status.storage, .notDownloaded) + } + + func test_deleteModel_gemma_완료() async { + // Arrange + let sut = makeSUT() + let model = ChaGokModel.gemma4_e2b_4bit + sut.mockAvailableModelRepo.setFetchSupportModelsResult([ + ChaGokModelState( + title: "gemma4 title", + subTitle: "gemma4 subTitle", + model: .gemma4_e2b_4bit, + status: OnDeviceStatus(storage: .downloaded) + ) + ]) + + // Act + sut.viewModel.checkModels() + try? await Task.sleep(nanoseconds: 100_000_000) + sut.viewModel.deleteModel(model: model) + try? await Task.sleep(nanoseconds: 500_000_000) + + // Assert + XCTAssertEqual(sut.viewModel.models[0].status.storage, .notDownloaded) + } + + // MARK: - Coordinator Tests + + func test_pop_코디네이터호출() { + // Arrange + let sut = makeSUT() + + // Act + sut.viewModel.pop() + + // Assert + XCTAssertTrue(sut.mockCoordinator.popCalled) + } + + func test_pushTermsOfUse_코디네이터호출() { + // Arrange + let sut = makeSUT() + + // Act + sut.viewModel.pushTermsOfUse() + + // Assert + XCTAssertTrue(sut.mockCoordinator.pushTermsCalled) + } + + func test_pushPrivacyPolicy_코디네이터호출() { + // Arrange + let sut = makeSUT() + + // Act + sut.viewModel.pushPrivacyPolicy() + + // Assert + XCTAssertTrue(sut.mockCoordinator.pushPrivacyCalled) + } + + // MARK: - None Model Tests + + func test_downloadModel_none_무시됨() async { + // Arrange + let sut = makeSUT() + + // Act + sut.viewModel.downloadModel(model: .none) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + // 아무 일도 일어나지 않음 (에러도 없음) + XCTAssertTrue(true) + } + + func test_deleteModel_none_무시됨() async { + // Arrange + let sut = makeSUT() + + // Act + sut.viewModel.deleteModel(model: .none) + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + // 아무 일도 일어나지 않음 (에러도 없음) + XCTAssertTrue(true) + } +} diff --git a/Presentation/Tests/Trash/TrashViewModelTests.swift b/Presentation/Tests/Trash/TrashViewModelTests.swift new file mode 100644 index 00000000..cb6a0692 --- /dev/null +++ b/Presentation/Tests/Trash/TrashViewModelTests.swift @@ -0,0 +1,289 @@ +@testable import Presentation +import Domain +import DomainTesting +import XCTest + +@MainActor +final class MockTrashCoordinatorDelegate: TrashCoordinatorDelegate { + var popCalled = false + var pushedVoiceNote: VoiceNote? + var pushedFolder: Folder? + var pushSearchViewCalled = false + var pushedSearchType: SearchViewModel.SearchType? + var pushedSearchItems: [ContentItem] = [] + + func pop() { + popCalled = true + } + + func pushVoiceNoteView(voiceNote: VoiceNote, isTrashMode: Bool) { + pushedVoiceNote = voiceNote + } + + func pushMyFolderDetailView(_ folder: Folder, isHidden: Bool) { + pushedFolder = folder + } + + func pushSearchView(type: SearchViewModel.SearchType, items: [ContentItem], isHidden: Bool) { + pushSearchViewCalled = true + pushedSearchType = type + pushedSearchItems = items + } +} + +@MainActor +final class TrashViewModelTests: XCTestCase { + // MARK: - SUT + + private struct SUT { + let viewModel: TrashViewModel + let mockFolderRepo: MockFolderRepository + let mockVoiceNoteRepo: MockVoiceNoteRepository + let mockCoordinator: MockTrashCoordinatorDelegate + } + + private func makeSUT() -> SUT { + let mockFolderRepo = MockFolderRepository() + let mockVoiceNoteRepo = MockVoiceNoteRepository() + let mockCoordinator = MockTrashCoordinatorDelegate() + + let viewModel = TrashViewModel( + folderUseCase: DefaultFolderUseCase(repository: mockFolderRepo), + voiceNoteUseCase: DefaultVoiceNoteUseCase( + repository: mockVoiceNoteRepo, + folderRepository: mockFolderRepo, + analysisService: MockVoiceNoteAnalysisService() + ) + ) + viewModel.coordinator = mockCoordinator + + return SUT( + viewModel: viewModel, + mockFolderRepo: mockFolderRepo, + mockVoiceNoteRepo: mockVoiceNoteRepo, + mockCoordinator: mockCoordinator + ) + } + + private func setTrashStreams( + _ sut: SUT, + items: [ContentItem] + ) { + var folders: [Folder] = [] + var notes: [VoiceNote] = [] + for item in items { + switch item { + case .folder(let folder): folders.append(folder) + case .voiceNote(let note): notes.append(note) + } + } + sut.mockFolderRepo.setObserveTrashedResult(.success(makeStream(folders))) + sut.mockVoiceNoteRepo.setObserveTrashedResult(.success(makeStream(notes))) + } + + private func makeStream(_ value: T) -> AsyncStream { + AsyncStream { continuation in + continuation.yield(value) + continuation.finish() + } + } + + // MARK: - Initial State Tests + + func test_초기상태_확인() { + let sut = makeSUT() + + XCTAssertTrue(sut.viewModel.items.isEmpty, "초기 항목 배열은 비어있어야 합니다.") + XCTAssertNil(sut.viewModel.errorMessage, "초기 에러 메시지는 없어야 합니다.") + } + + func test_deleteButtonTapped_선택항목존재시_alertAction호출() { + let sut = makeSUT() + let folder = Folder(name: "테스트 폴더") + sut.viewModel.selectItem(.folder(folder)) + + var alertActionCalled = false + sut.viewModel.deleteButtonTapped { + alertActionCalled = true + } + + XCTAssertTrue(alertActionCalled, "선택된 항목이 있으면 alertAction이 호출되어야 합니다.") + } + + func test_deleteButtonTapped_선택항목없을시_상태원복() { + let sut = makeSUT() + sut.viewModel.setSelectionMode(.multiple) + XCTAssertEqual(sut.viewModel.select, .multiple) + + var alertActionCalled = false + sut.viewModel.deleteButtonTapped { + alertActionCalled = true + } + + XCTAssertFalse(alertActionCalled, "선택된 항목이 없으면 alertAction이 호출되지 않아야 합니다.") + XCTAssertEqual(sut.viewModel.select, .none, "선택 모드가 none으로 원복되어야 합니다.") + } + + func test_didTapBack_코디네이터pop호출() { + let sut = makeSUT() + XCTAssertFalse(sut.mockCoordinator.popCalled) + + sut.viewModel.didTapBack() + + XCTAssertTrue(sut.mockCoordinator.popCalled, "뒤로가기 시 pop이 정상 호출되어야 합니다.") + } + + func test_pushSearch_호출시_화면전환() async { + let sut = makeSUT() + let folder = Folder(name: "삭제된 폴더") + setTrashStreams(sut, items: [.folder(folder)]) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.viewModel.pushSearch() + + XCTAssertTrue(sut.mockCoordinator.pushSearchViewCalled) + XCTAssertEqual(sut.mockCoordinator.pushedSearchType, .trash) + XCTAssertEqual(sut.mockCoordinator.pushedSearchItems.count, 1) + } + + // MARK: - Update & Fetch Tests + + func test_fetchItems_정상적으로_가져오기() async { + let sut = makeSUT() + let fetchResult: [ContentItem] = [ + .folder(Folder(name: "테스트 폴더")), + .voiceNote(VoiceNote( + title: "테스트 노트", + folderID: UUID(), + voiceRecord: VoiceRecord(audioFilePath: "VoiceRecords/null.m4a", duration: 10), + analysisState: .pending + )) + ] + + setTrashStreams(sut, items: fetchResult) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(sut.viewModel.items.count, 2, "2개의 항목을 정상적으로 불러와야 합니다.") + } + + func test_fetchItems_정렬_확인() async { + let sut = makeSUT() + let now = Date() + let items: [ContentItem] = [ + .folder(Folder(name: "오래된 삭제", deletedAt: now.addingTimeInterval(-1000))), + .folder(Folder(name: "최근 삭제", deletedAt: now)), + .folder(Folder(name: "중간 삭제", deletedAt: now.addingTimeInterval(-500))) + ] + setTrashStreams(sut, items: items) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(sut.viewModel.items.count, 3) + XCTAssertEqual(sut.viewModel.items[0].deletedAt, now, "가장 최근 삭제된 항목이 첫 번째여야 합니다.") + XCTAssertEqual(sut.viewModel.items[1].deletedAt, now.addingTimeInterval(-500)) + XCTAssertEqual(sut.viewModel.items[2].deletedAt, now.addingTimeInterval(-1000), "가장 오래된 삭제된 항목이 마지막이어야 합니다.") + } + + // MARK: - Delete & Restore Tests + + func test_deleteAll_정상수행() async { + let sut = makeSUT() + let fetchResult: [ContentItem] = [ + .folder(Folder(name: "테스트 폴더")) + ] + setTrashStreams(sut, items: fetchResult) + sut.mockFolderRepo.expectDelete(callCount: 1) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(sut.viewModel.items.count, 1) + + sut.viewModel.deleteAll() + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + XCTAssertTrue(sut.viewModel.items.isEmpty, "전체 삭제 진행 후 items 배열이 비워져야 합니다.") + } + + func test_deleteItem_단일항목삭제() async { + let sut = makeSUT() + let folder = Folder(name: "삭제용 폴더") + let item = ContentItem.folder(folder) + setTrashStreams(sut, items: [item]) + sut.mockFolderRepo.expectDelete(callCount: 1) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.viewModel.delete(item: item) + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + XCTAssertTrue(sut.viewModel.items.isEmpty, "단일 삭제 진행 후 항목이 리스트에서 지워져야 합니다.") + } + + func test_restoreItem_단일항목복구() async { + let sut = makeSUT() + let folder = Folder(name: "복구용 폴더", deletedAt: .now, parentID: UUID()) + let item = ContentItem.folder(folder) + setTrashStreams(sut, items: [item]) + sut.mockFolderRepo.setFetchByIDResult(.success(folder)) + sut.mockFolderRepo.setUpdateResult(.success(folder)) + sut.mockFolderRepo.expectUpdate(folderID: folder.id, callCount: 1) + + sut.viewModel.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.viewModel.restore(item: item) + try? await Task.sleep(nanoseconds: 300_000_000) + + sut.mockFolderRepo.verify() + XCTAssertTrue(sut.viewModel.items.isEmpty, "복원 후 휴지통 목록에서 항목이 제거되어야 합니다.") + } + + func test_cancelRestoreItem_단일항목복원취소() { + let sut = makeSUT() + let folder = Folder(name: "복원취소용 폴더") + let trash = Folder.stub(kind: .trash) + let item = ContentItem.folder(folder) + sut.mockFolderRepo.setFetchByKindResult(.trash, result: .success([trash])) + sut.mockFolderRepo.setFetchByIDResult(.success(folder)) + sut.mockFolderRepo.setUpdateResult(.success(folder)) + sut.mockFolderRepo.expectUpdate(folderID: folder.id, callCount: 1) + + sut.viewModel.cancelRestore(item: item) + + sut.mockFolderRepo.verify() + XCTAssertEqual(sut.viewModel.items.count, 1, "복원 취소 후 항목이 다시 휴지통에 추가되어야 합니다.") + } + + func test_cancelRestoreItems_복수항목복원취소() { + let sut = makeSUT() + let folder = Folder(name: "복원취소용 폴더 1") + let voiceNote = VoiceNote( + title: "복원취소용 노트 1", + folderID: UUID(), + voiceRecord: VoiceRecord(audioFilePath: "test.m4a", duration: 10), + analysisState: .pending + ) + let trash = Folder.stub(kind: .trash) + let items = [ + ContentItem.folder(folder), + ContentItem.voiceNote(voiceNote) + ] + sut.mockFolderRepo.setFetchByKindResult(.trash, result: .success([trash])) + sut.mockFolderRepo.setFetchByIDResult(.success(folder)) + sut.mockFolderRepo.setUpdateResult(.success(folder)) + sut.mockVoiceNoteRepo.setFetchResult(.success(voiceNote)) + sut.mockVoiceNoteRepo.setUpdateResult(.success(voiceNote)) + + sut.viewModel.cancelRestore(items: items) + + XCTAssertEqual(sut.viewModel.items.count, 2, "복원 취소 후 모든 항목이 다시 휴지통에 추가되어야 합니다.") + } +} diff --git a/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift b/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift new file mode 100644 index 00000000..04166ac3 --- /dev/null +++ b/Presentation/Tests/VoiceNote/VoiceNoteViewModelSearchTest.swift @@ -0,0 +1,425 @@ +@testable import Presentation +import Domain +import DomainTesting +import Foundation +import XCTest + +@MainActor +final class VoiceNoteViewModelSearchTest: XCTestCase { + // MARK: - SUT + + private struct SUT { + let viewModel: VoiceNoteViewModel + let playbackRepository: FakeVoiceRecordPlaybackRepository + } + + private func makeSUT( + summary: Summary? = Summary( + text: "채용 시장의 변화\n이력서 작성 팁\n면접 기출 질문" + ), + transcript: Transcript? = Transcript(sections: [ + TranscriptSection(timestamp: 0, text: "채용 공고에 대한 이야기를 나눴습니다."), + TranscriptSection(timestamp: 30, text: "요즘 채용 시장에서 채용 공고 수가 줄었다고 합니다."), + TranscriptSection(timestamp: 90, text: "면접 관련 논의는 다음 회의에서 이어갑니다.") + ]), + keywords: [Keyword] = [ + Keyword(noteID: UUID(), word: "채용시장"), + Keyword(noteID: UUID(), word: "이력서"), + Keyword(noteID: UUID(), word: "면접") + ], + analysisState: AnalysisState = .completed + ) -> SUT { + let noteID = UUID() + let mappedKeywords = keywords.map { Keyword(noteID: noteID, word: $0.word) } + + let voiceNote = VoiceNote.stub( + id: noteID, + title: "검색 테스트 노트", + keywords: mappedKeywords, + transcript: transcript, + summary: summary, + analysisState: analysisState + ) + + let playbackRepository = FakeVoiceRecordPlaybackRepository() + + let viewModel = VoiceNoteViewModel( + voiceNote: voiceNote, + voiceNoteUseCase: FakeVoiceNoteUseCase(voiceNote: voiceNote), + folderUseCase: FakeFolderUseCase(), + playbackRepository: playbackRepository, + availableSupportModelRepository: FakeAvailableModelSupportRepository() + ) + + return SUT(viewModel: viewModel, playbackRepository: playbackRepository) + } + + /// 플레이백 스트림이 초기 상태를 yield하고 ViewModel이 반영할 때까지 대기합니다. + private func activatePlayback( + _ sut: SUT, + status: AudioPlaybackState.Status + ) async { + sut.playbackRepository.initialStatus = status + sut.viewModel.onAppear() + // AsyncStream의 yield가 ViewModel의 currentPlaybackState에 반영될 때까지 대기 + for _ in 0 ..< 20 where sut.viewModel.currentPlaybackState.status != status { + await Task.yield() + } + } +} + +// MARK: - 검색 모드 진입/종료 + +extension VoiceNoteViewModelSearchTest { + func test_초기상태_searchMode가false이고_matchIndex는0이다() { + // Given/When + let sut = makeSUT() + + // Then + XCTAssertFalse(sut.viewModel.searchMode) + XCTAssertEqual(sut.viewModel.searchQuery, "") + XCTAssertEqual(sut.viewModel.currentMatchIndex, 0) + XCTAssertTrue(sut.viewModel.summaryMatches.isEmpty) + XCTAssertTrue(sut.viewModel.scriptMatches.isEmpty) + } + + func test_검색모드진입시_재생중이면_pause된다() async { + // Given + let sut = makeSUT() + await activatePlayback(sut, status: .playing) + let pausesBefore = sut.playbackRepository.pauseCallCount + + // When + sut.viewModel.enterSearchMode() + + // Then + XCTAssertTrue(sut.viewModel.searchMode) + XCTAssertEqual(sut.playbackRepository.pauseCallCount - pausesBefore, 1) + } + + func test_검색모드종료시_재생중이었어도_재생을호출하지않는다() async { + // Given + let sut = makeSUT() + await activatePlayback(sut, status: .playing) + sut.viewModel.enterSearchMode() + let playsBefore = sut.playbackRepository.playCallCount + + // When + sut.viewModel.exitSearchMode() + + // Then + XCTAssertFalse(sut.viewModel.searchMode) + XCTAssertEqual(sut.viewModel.searchQuery, "") + XCTAssertEqual(sut.playbackRepository.playCallCount - playsBefore, 0) + } + + func test_검색모드종료시_재생중이아니었으면_재생을호출하지않는다() async { + // Given + let sut = makeSUT() + await activatePlayback(sut, status: .idle) + sut.viewModel.enterSearchMode() + let playsBefore = sut.playbackRepository.playCallCount + + // When + sut.viewModel.exitSearchMode() + + // Then + XCTAssertEqual(sut.playbackRepository.playCallCount - playsBefore, 0) + } +} + +// MARK: - 매치 계산 + +extension VoiceNoteViewModelSearchTest { + func test_검색쿼리가비어있으면_매치가0개다() { + // Given + let sut = makeSUT() + + // When + sut.viewModel.enterSearchMode() + + // Then + XCTAssertTrue(sut.viewModel.summaryMatches.isEmpty) + XCTAssertTrue(sut.viewModel.scriptMatches.isEmpty) + } + + func test_대소문자다른쿼리로도_매치를찾는다() { + // Given + let sut = makeSUT( + transcript: Transcript(sections: [ + TranscriptSection(timestamp: 0, text: "iOS와 ios 모두 표기됩니다.") + ]) + ) + sut.viewModel.enterSearchMode() + + // When + sut.viewModel.updateCurrentPage(.script) + sut.viewModel.updateSearchQuery("ios") + + // Then + XCTAssertEqual(sut.viewModel.scriptMatches.count, 2) + } + + func test_핵심포인트와키워드에서만_요약매치가계산된다() { + // Given + let sut = makeSUT() + sut.viewModel.enterSearchMode() + + // When + sut.viewModel.updateSearchQuery("채용") + + // Then + // 핵심 포인트 "채용 시장의 변화"에서 1건, 키워드 "채용시장"에서 1건 + // (keywords는 정렬되므로 "면접","이력서","채용시장" 순 → "채용시장"은 index 2) + XCTAssertEqual(sut.viewModel.summaryMatches.count, 2) + + guard sut.viewModel.summaryMatches.count == 2 else { return } + XCTAssertEqual(sut.viewModel.summaryMatches[0].location, .keyPoint(index: 0)) + XCTAssertEqual(sut.viewModel.summaryMatches[1].location, .keyword(index: 2)) + } + + func test_스크립트매치는_섹션순서와내부위치순서로정렬된다() { + // Given + let sut = makeSUT() + sut.viewModel.enterSearchMode() + + // When + sut.viewModel.updateSearchQuery("채용") + + // Then + // 섹션 0: 1개, 섹션 1: 2개 (채용 시장에서 / 채용 공고) → 총 3 + let matches = sut.viewModel.scriptMatches + XCTAssertEqual(matches.count, 3) + XCTAssertEqual(matches[0].location, .script(sectionIndex: 0)) + XCTAssertEqual(matches[1].location, .script(sectionIndex: 1)) + XCTAssertEqual(matches[2].location, .script(sectionIndex: 1)) + XCTAssertLessThan(matches[1].range.location, matches[2].range.location) + } +} + +// MARK: - 매치 이동 + +extension VoiceNoteViewModelSearchTest { + func test_다음매치이동시_인덱스가순환한다() { + // Given + let sut = makeSUT() + sut.viewModel.enterSearchMode() + sut.viewModel.updateCurrentPage(.script) + sut.viewModel.updateSearchQuery("채용") // 3개 + + // When / Then + XCTAssertEqual(sut.viewModel.currentMatchIndex, 0) + + sut.viewModel.nextMatch() + XCTAssertEqual(sut.viewModel.currentMatchIndex, 1) + + sut.viewModel.nextMatch() + XCTAssertEqual(sut.viewModel.currentMatchIndex, 2) + + sut.viewModel.nextMatch() + XCTAssertEqual(sut.viewModel.currentMatchIndex, 0) + } + + func test_이전매치이동시_첫매치에서_마지막으로순환한다() { + // Given + let sut = makeSUT() + sut.viewModel.enterSearchMode() + sut.viewModel.updateCurrentPage(.script) + sut.viewModel.updateSearchQuery("채용") // 3개 + + // When + sut.viewModel.previousMatch() + + // Then + XCTAssertEqual(sut.viewModel.currentMatchIndex, 2) + } + + func test_매치가없으면_next및previous는noop이다() { + // Given + let sut = makeSUT() + sut.viewModel.enterSearchMode() + sut.viewModel.updateSearchQuery("절대없는단어xyz") + + // When + sut.viewModel.nextMatch() + sut.viewModel.previousMatch() + + // Then + XCTAssertEqual(sut.viewModel.currentMatchIndex, 0) + } + + func test_페이지전환시_currentMatchIndex가0으로리셋된다() { + // Given + let sut = makeSUT() + sut.viewModel.enterSearchMode() + sut.viewModel.updateSearchQuery("채용") + sut.viewModel.nextMatch() // index = 1 + + // When + sut.viewModel.updateCurrentPage(.script) + + // Then + XCTAssertEqual(sut.viewModel.currentMatchIndex, 0) + } + + func test_검색쿼리변경시_currentMatchIndex가0으로리셋된다() { + // Given + let sut = makeSUT() + sut.viewModel.enterSearchMode() + sut.viewModel.updateSearchQuery("채용") + sut.viewModel.nextMatch() // 1 + + // When + sut.viewModel.updateSearchQuery("면접") + + // Then + XCTAssertEqual(sut.viewModel.currentMatchIndex, 0) + } +} + +// MARK: - Fakes + +@MainActor +private final class FakeVoiceRecordPlaybackRepository: VoiceRecordPlaybackRepository { + var initialStatus: AudioPlaybackState.Status = .idle + var playCallCount = 0 + var pauseCallCount = 0 + var seekCallCount = 0 + var stopCallCount = 0 + + func prepare(audioFilePath _: String) throws(VoiceRecordPlaybackRepositoryError) + -> AsyncStream + { + let status = initialStatus + return AsyncStream { continuation in + continuation.yield(AudioPlaybackState(status: status, currentTime: 0, duration: 100)) + continuation.finish() + } + } + + func play() throws(VoiceRecordPlaybackRepositoryError) { + playCallCount += 1 + } + + func pause() throws(VoiceRecordPlaybackRepositoryError) { + pauseCallCount += 1 + } + + func seek(to _: TimeInterval) throws(VoiceRecordPlaybackRepositoryError) { + seekCallCount += 1 + } + + func stop() throws(VoiceRecordPlaybackRepositoryError) { + stopCallCount += 1 + } +} + +private struct FakeVoiceNoteUseCase: VoiceNoteUseCase { + let voiceNote: VoiceNote + + func create(_ voiceRecord: VoiceRecord) throws(VoiceNoteUseCaseError) -> VoiceNote { + VoiceNote.stub(voiceRecord: voiceRecord) + } + + func fetch(byId _: UUID) throws(VoiceNoteUseCaseError) -> VoiceNote { + voiceNote + } + + func update(_ voiceNote: VoiceNote) throws(VoiceNoteUseCaseError) -> VoiceNote { + voiceNote + } + + func observe(id _: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream { + AsyncStream { continuation in + continuation.yield(voiceNote) + continuation.finish() + } + } + + func observe(folderID _: UUID) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + AsyncStream { continuation in + continuation.yield([voiceNote]) + continuation.finish() + } + } + + func observeRecent(limit _: Int) throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + AsyncStream { continuation in + continuation.yield([voiceNote]) + continuation.finish() + } + } + + func observeTrashed() throws(VoiceNoteUseCaseError) -> AsyncStream<[VoiceNote]> { + AsyncStream { $0.finish() } + } + + func regenerateSummary(id _: UUID) {} + + func moveToTrash(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func restore(noteID _: UUID) throws(VoiceNoteUseCaseError) {} + func delete(noteID _: UUID) throws(VoiceNoteUseCaseError) {} +} + +private struct FakeFolderUseCase: FolderUseCase { + func create(name: String) throws(FolderUseCaseError) -> Folder { + Folder(name: name, kind: .custom) + } + + func createDefault() throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func createTrash() throws(FolderUseCaseError) -> Folder { + Folder(name: "휴지통", kind: .trash) + } + + func fetchAll() throws(FolderUseCaseError) -> [Folder] { + [Folder(name: "기본 폴더", kind: .default)] + } + + func fetchDefault() throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func fetchTrash() throws(FolderUseCaseError) -> Folder { + Folder(name: "휴지통", kind: .trash) + } + + func fetchDeletableFolders() throws(FolderUseCaseError) -> [Folder] { + [] + } + + func fetch(by _: UUID) throws(FolderUseCaseError) -> Folder { + Folder(name: "기본 폴더", kind: .default) + } + + func update(_ folder: Folder) throws(FolderUseCaseError) -> Folder { + folder + } + + func observeCustom() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + AsyncStream { continuation in + continuation.yield([]) + continuation.finish() + } + } + + func observeTrashed() throws(FolderUseCaseError) -> AsyncStream<[Folder]> { + AsyncStream { $0.finish() } + } + + func moveToTrash(folderID _: UUID) throws(FolderUseCaseError) {} + func restore(folderID _: UUID) throws(FolderUseCaseError) {} + func delete(folderID _: UUID) throws(FolderUseCaseError) {} +} + +private struct FakeAvailableModelSupportRepository: AvailableModelSupportRepository { + func checkMLXSupportModel() async -> ChaGokModelSupport { + ChaGokModelSupport(ramSizeGB: 8, isProUser: false) + } + + func fetchSupportModels() async -> [ChaGokModelState] { + [] + } +} diff --git a/README.md b/README.md deleted file mode 100644 index 65d06baa..00000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -# iOS \ No newline at end of file diff --git a/Tuist.swift b/Tuist.swift new file mode 100644 index 00000000..245d3c96 --- /dev/null +++ b/Tuist.swift @@ -0,0 +1,6 @@ +import ProjectDescription + +let tuist = Tuist( + fullHandle: "ChaGokProject/ios", + project: .tuist() +) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved new file mode 100644 index 00000000..b677182d --- /dev/null +++ b/Tuist/Package.resolved @@ -0,0 +1,159 @@ +{ + "originHash" : "116fda260e0037f8a3edd7768683fed1bab7cd842ad60b8d9198066efd71f54f", + "pins" : [ + { + "identity" : "argmax-oss-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/argmaxinc/argmax-oss-swift.git", + "state" : { + "revision" : "25c62997041c134b03ca82731ce2f6fd2cae1eb9", + "version" : "1.0.0" + } + }, + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/EventSource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "61b9e011e09a62b489f6bd647958f1555bdf2896", + "version" : "0.31.3" + } + }, + { + "identity" : "mlx-swift-lm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift-lm", + "state" : { + "revision" : "1c05248bb0899e2a7a4962b84d319cf12f4e12aa", + "version" : "3.31.3" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "03cc312c2c933ed87abace34044a5dff7a3117c1", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-huggingface", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-huggingface", + "state" : { + "revision" : "b721959445b617d0bf03910b2b4aced345fd93bf", + "version" : "0.9.0" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "0aeefadec459ce8e11a333769950fb86183aca43", + "version" : "2.3.5" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "349a7ce54ccb8ebe4bc10c2022e9806f01adb7c6", + "version" : "1.3.2" + } + }, + { + "identity" : "yyjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ibireme/yyjson.git", + "state" : { + "revision" : "8b4a38dc994a110abaec8a400615567bd996105f", + "version" : "0.12.0" + } + } + ], + "version" : 3 +} diff --git a/Tuist/Package.swift b/Tuist/Package.swift new file mode 100644 index 00000000..406c6da2 --- /dev/null +++ b/Tuist/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ChaGok", + dependencies: [ + .package(url: "https://github.com/argmaxinc/argmax-oss-swift.git", from: "1.0.0"), + .package( + url: "https://github.com/ml-explore/mlx-swift-lm", + .upToNextMajor(from: "3.31.3") + ), + .package(url: "https://github.com/huggingface/swift-huggingface", from: "0.9.0"), + .package(url: "https://github.com/huggingface/swift-transformers", from: "1.3.0") + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Config.swift b/Tuist/ProjectDescriptionHelpers/Config.swift new file mode 100644 index 00000000..f1abc52f --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Config.swift @@ -0,0 +1,35 @@ +import ProjectDescription + +public let bundleId = "com.yongms.ChaGokChaGok" +public let displayName = "차곡" +public let version = "1.0.0" +public let build = "1" +public let iOSVersion = "26.0" +public let deploymentTargets: DeploymentTargets = .iOS(iOSVersion) +public let style = "Dark" + +public let settings: Settings = .settings( + base: [ + "IPHONEOS_DEPLOYMENT_TARGET": SettingValue(stringLiteral: iOSVersion), + "SWIFT_VERSION": "6.0", + "PRODUCT_BUNDLE_DISPLAY_NAME": SettingValue(stringLiteral: displayName), + "MARKETING_VERSION": SettingValue(stringLiteral: version), + "CURRENT_PROJECT_VERSION": SettingValue(stringLiteral: build), + // iPhone 전용 앱 (iPad 아이콘 불필요) + "TARGETED_DEVICE_FAMILY": "1", + // 실제 기기 빌드를 위한 사이닝 설정 + "DEVELOPMENT_TEAM": "78QTJM9AD7", + "CODE_SIGN_STYLE": "Manual", + "CODE_SIGNING_REQUIRED": "YES", + "CODE_SIGNING_ALLOWED": "YES", + // 에셋 카탈로그 → Swift 심볼 자동 생성 (타입 세이프 접근) + "ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS": "YES", + // String Catalog → Swift 심볼 생성 (Xcode "Enable String Catalog Symbol Generation") + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + // Module Verifier는 순수 Swift 프레임워크에서 Obj-C 검증 실패를 유발하므로 비활성화 + "ENABLE_MODULE_VERIFIER": "NO", + // Run Script가 정상 동작하도록 User Script Sandboxing 비활성화 + "ENABLE_USER_SCRIPT_SANDBOXING": "NO" + ], + defaultSettings: .recommended +) diff --git a/Workspace.swift b/Workspace.swift new file mode 100644 index 00000000..c7fef1ae --- /dev/null +++ b/Workspace.swift @@ -0,0 +1,13 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let workspace = Workspace( + name: "ChaGok", + projects: [ + "App", + "Core", + "Domain", + "Data", + "Presentation" + ] +) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..6d108109 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,90 @@ +# 차곡 (ChaGok) + +서버 업로드 없이 녹음부터 전사, 키워드 추출, 요약까지 기기 안에서 처리하는 iOS 음성 기록 앱입니다. + +![차곡 대표 이미지](assets/hero.png) + +## 프로젝트 소개 + +차곡은 회의, 강의, 인터뷰, 아이디어 메모처럼 나중에 다시 찾아야 하는 음성 기록을 안전하게 쌓아두기 위한 앱입니다. 사용자는 앱에서 바로 녹음하고, 녹음이 끝나면 WhisperKit 기반 STT와 MLX 기반 온디바이스 LLM 요약 파이프라인을 통해 스크립트, 키워드, 핵심 요약을 확인할 수 있습니다. + +## 문제 정의와 해결 방향 + +![핵심 사용자 페르소나](assets/user-personas.svg) + +![사용자가 겪는 문제와 해결 방식](assets/problem-solution.svg) + +## 주요 기능 + +![주요 기능](assets/key-features.svg) + +- 녹음: 마이크 권한 확인, 녹음 시작/일시정지/재개/종료, 실시간 파형 표시 +- 온디바이스 분석: WhisperKit 전사, MLX 기반 요약, 키워드와 핵심 포인트 생성 +- 음성 노트 상세: 요약/스크립트 탭, 오디오 재생, 타임스탬프 이동, 제목 및 스크립트 편집 +- 정리와 검색: 최근 기록, 전체 기록, 폴더, 휴지통, 검색 결과 하이라이트 +- 모델과 설정: 온보딩/설정에서 모델 다운로드와 삭제, 기록 언어와 앱 정책 관리 + +## 대표 화면 + +| 기록 목록 | 스크립트 | 요약 | +|:--------------------------------------------------------------------------:|:---------------------------------------------------------------------------:|:------------------------------------------------------------------------:| +| 기록 목록 화면 | 스크립트 화면 | 요약 화면 | +| 최근 기록, 기본 폴더, 개인 폴더, 휴지통을 한 화면에서 확인합니다. | 타임스탬프가 있는 스크립트와 오디오 재생 컨트롤을 함께 제공합니다. | 핵심 포인트, 키워드, 요약 재생성을 통해 긴 기록을 빠르게 파악합니다. | + +## 아키텍처 및 폴더 구조 + +![아키텍처 다이어그램](assets/architecture.svg) + +### 폴더 구조 + +```text +ChaGok +├── App # 앱 진입점, DI, Coordinator +├── Presentation # UIKit 화면, ViewModel, 재사용 UI 컴포넌트 +├── Domain # Entity, UseCase, Repository Interface, 도메인 정책 +├── Data # Repository 구현, Core Data, 온디바이스 모델 Provider +├── Core # Logger, 날짜/시간 포맷, 공통 확장과 유틸리티 +├── Tuist # 프로젝트 설정과 외부 패키지 정의 +└── fastlane # 인증서 동기화와 배포 lane +``` + +## 기술 스택 + +| 영역 | 사용 기술 | +|---------------|------------------------------------------------------| +| Language | Swift 6.0 | +| UI | UIKit, Observation, Auto Layout | +| Architecture | Layer-based multi-module, MVVM, Coordinator, Pure DI | +| Project | Tuist 4.158.0, mise | +| Local storage | Core Data, FileManager, UserDefaults | +| Audio | AVFoundation, Speech | +| On-device STT | WhisperKit, ArgmaxOSS | +| On-device LLM | MLX Swift, MLX Swift LM, HuggingFace, Tokenizers | +| Test | XCTest, DomainTesting mocks/stubs | +| Tooling | SwiftFormat, fastlane | + +## 실행 방법 + +### 요구 사항 + +- macOS와 Xcode +- iOS SDK 26.0 이상 +- Tuist 4.158.0 +- Ruby 3.3.7 (fastlane 사용 시) +- 실제 기기 빌드 시 코드 사이닝 프로파일 또는 fastlane match 접근 권한 + +### 프로젝트 생성 + +```bash +mise install +tuist install +tuist generate +``` + +## 문서 + +- [프로젝트 위키](../iOS.wiki/Home.md) +- [개발 표준](../iOS.wiki/Development/Development.md) +- [코딩 컨벤션](../iOS.wiki/Development/Coding-Convention.md) +- [테스트 전략](../iOS.wiki/Development/Testing-Strategy.md) +- [녹음 플로우](../iOS.wiki/Development/AudioService-Recording-Flow.md) diff --git a/docs/assets/app-icon-source.png b/docs/assets/app-icon-source.png new file mode 100644 index 00000000..6fe8a2bf Binary files /dev/null and b/docs/assets/app-icon-source.png differ diff --git a/docs/assets/app-icon.png b/docs/assets/app-icon.png new file mode 100644 index 00000000..d0c7ea68 Binary files /dev/null and b/docs/assets/app-icon.png differ diff --git a/docs/assets/architecture.svg b/docs/assets/architecture.svg new file mode 100644 index 00000000..478b3c3d --- /dev/null +++ b/docs/assets/architecture.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 아키텍처 다이어그램 + 도메인 규칙은 가운데에 두고, 화면과 데이터 구현은 인터페이스 경계를 통해 연결합니다. + + + + + + + + App + 앱 조립 지점 + DI Container · Coordinator · App/Scene Lifecycle + + + + + + + + + Presentation + UIKit 화면 + ViewModel + 재사용 UI 컴포넌트 + + + + + Domain + 앱의 규칙과 인터페이스 소유 + + + UseCase + + Entity + + Interface + + Policy + + + + + Data + Repository 구현 + Core Data · FileManager + WhisperKit · MLX Provider + + + + + + + + Core + Logger · Date/Time · Extensions · Error Handling · Shared Utilities + + + + + + 녹음 완료 + → VoiceNoteUseCase → VoiceNoteAnalysisService → 전사/요약 Repository → 로컬 저장 + + diff --git a/docs/assets/chagok-screen-list.png b/docs/assets/chagok-screen-list.png new file mode 100644 index 00000000..04b6d995 Binary files /dev/null and b/docs/assets/chagok-screen-list.png differ diff --git a/docs/assets/chagok-screen-script.png b/docs/assets/chagok-screen-script.png new file mode 100644 index 00000000..eba4d663 Binary files /dev/null and b/docs/assets/chagok-screen-script.png differ diff --git a/docs/assets/chagok-screen-summary.png b/docs/assets/chagok-screen-summary.png new file mode 100644 index 00000000..1437c394 Binary files /dev/null and b/docs/assets/chagok-screen-summary.png differ diff --git a/docs/assets/demo-note.svg b/docs/assets/demo-note.svg new file mode 100644 index 00000000..5ddf7eaa --- /dev/null +++ b/docs/assets/demo-note.svg @@ -0,0 +1,26 @@ + + + + 팀 회의 기록 + 기본 폴더 · 24분 + + + 요약 + 스크립트 + + + 출시 일정은 2주 앞당긴다. + + 요약 기능의 품질을 우선 검증한다. + + 온디바이스 모델 다운로드 UX를 보완한다. + + + + 회의 + + 일정 + + 요약 + + diff --git a/docs/assets/demo-organizing.svg b/docs/assets/demo-organizing.svg new file mode 100644 index 00000000..fe114ec8 --- /dev/null +++ b/docs/assets/demo-organizing.svg @@ -0,0 +1,25 @@ + + + + 전체 + + 검색어를 입력하세요 + + + + 최근 기록 + 5개 항목 + + + + + 프로젝트 회의 + 12개 기록 + + + + + 휴지통 + 복원 또는 영구 삭제 + + diff --git a/docs/assets/demo-recording.svg b/docs/assets/demo-recording.svg new file mode 100644 index 00000000..78f69294 --- /dev/null +++ b/docs/assets/demo-recording.svg @@ -0,0 +1,20 @@ + + + + 새 기록 + 2026.06.01 · 오전 10:41 + 00 : 12 : 34 + + + + + + + + + + + + + 종료 + diff --git a/docs/assets/hero.png b/docs/assets/hero.png new file mode 100644 index 00000000..5d039c03 Binary files /dev/null and b/docs/assets/hero.png differ diff --git a/docs/assets/hero.svg b/docs/assets/hero.svg new file mode 100644 index 00000000..bdaca290 --- /dev/null +++ b/docs/assets/hero.svg @@ -0,0 +1,22 @@ + + + + + + + 차곡 + 녹음부터 요약까지, 내 기기에서 한 번에 + + + + + 음성 기록을 로컬에서 분석 + WhisperKit STT · MLX 요약 · Core Data 저장 + + + + + + + + diff --git a/docs/assets/key-features.svg b/docs/assets/key-features.svg new file mode 100644 index 00000000..30e306bb --- /dev/null +++ b/docs/assets/key-features.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 주요 기능 + 녹음부터 요약, 검색과 폴더 관리까지 이어지는 온디바이스 음성 기록 흐름 + + + + + + + + + 녹음 + 권한 확인과 파형 표시 + 일시정지·재개·저장 + + + + + + + + + 전사 + WhisperKit 기반 STT + 녹음을 스크립트로 변환 + + + + + + + + + keyword + 요약 + MLX 기반 핵심 요약 + 키워드와 포인트 생성 + + + + + + + + + 정리 + 폴더·검색·휴지통 + 기록을 계속 관리 + + + + + 세부 기능 + + + + 타임스탬프 재생 + + + + 스크립트 편집·재요약 + + + + 모델 다운로드 관리 + + + + 언어 설정 + + + + 앱 정책 + + + diff --git a/docs/assets/problem-solution.svg b/docs/assets/problem-solution.svg new file mode 100644 index 00000000..ccd5a75e --- /dev/null +++ b/docs/assets/problem-solution.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 사용자가 겪는 문제와 해결 방식 + 녹음 파일을 다시 찾고 활용하기 쉬운 정보로 바꾸는 방식 + + + + 사용자 문제 + + + + + + 다시 듣는 시간이 길어짐 + 긴 녹음에서 필요한 부분을 찾기 어렵습니다. + + + + + + + 음성 업로드가 부담됨 + 민감한 원본 음성을 서버에 맡기기 어렵습니다. + + + + + + + + + 기록이 늘수록 찾기 어려움 + 녹음이 쌓이면 정리와 검색 비용이 커집니다. + + + + + + + + 원문 수정 후 요약 신뢰 저하 + 스크립트가 바뀌면 요약도 다시 맞춰야 합니다. + + + + + + + + + + + 해결 방식 + + + + + + 전사와 요약 자동 생성 + 스크립트, 키워드, 핵심 포인트를 함께 만듭니다. + + + + + + + + 온디바이스 로컬 처리 + WhisperKit과 MLX 모델을 기기 안에서 실행합니다. + + + + + + + + + 폴더와 검색 중심 관리 + 최근 기록, 폴더, 휴지통, 검색으로 흐름을 나눕니다. + + + + + + + + 요약 재생성과 최신성 관리 + 원문 편집 후 다시 요약해 결과를 맞춥니다. + + + diff --git a/docs/assets/user-personas.svg b/docs/assets/user-personas.svg new file mode 100644 index 00000000..8d97e8dc --- /dev/null +++ b/docs/assets/user-personas.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 핵심 사용자 페르소나 + 차곡이 우선 해결하는 두 가지 기록 상황 + + + + + + + + + + + + + + + + + + + + + + 보안형 업무 기록자 + 회의·인터뷰·내부 논의 + + + + 외부 업로드 없이 안전하게 보관 + + + + 온디바이스 + + + + 빠른 회고 + + + + + + + + + + + + + + + + + + + + + + 학습형 기록 정리자 + 강의·스터디·아이디어 메모 + + + + 긴 녹음에서 핵심만 빠르게 파악 + + + + AI 요약 + + + + 폴더·검색 + + + diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 00000000..7e04b36d --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,5 @@ +app_identifier("com.yongms.ChaGokChaGok") +team_id("78QTJM9AD7") + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 00000000..dc87cde0 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,78 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:ios) + +platform :ios do + desc "코드 사이닝 인증서 및 프로비저닝 프로파일 동기화" + lane :sync_certificates do |options| + match(type: options[:type] || "appstore") + end + + def marketing_version + config = File.read("../Tuist/ProjectDescriptionHelpers/Config.swift") + match_data = config.match(/public let version = "(.+)"/) + UI.user_error!("Config.swift에서 version을 찾을 수 없습니다.") unless match_data + match_data[1] + end + + desc "TestFlight 베타 배포" + lane :beta do + setup_ci + setup_api_key + sync_certificates + build_number = Time.now.utc.strftime("%Y%m%d%H%M") + build_ipa(build_number: build_number) + upload_to_testflight(skip_waiting_for_build_processing: true) + end + + desc "App Store 배포" + lane :release do + setup_api_key + sync_certificates + build_number = Time.now.utc.strftime("%Y%m%d%H%M") + build_ipa(build_number: build_number) + upload_to_app_store + end + + private_lane :setup_api_key do + app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"], + key_content: ENV["APP_STORE_CONNECT_PRIVATE_KEY"], + is_key_content_base64: true, + in_house: false + ) + end + + private_lane :build_ipa do |options| + sh("cd .. && tuist install && tuist generate --no-open") + build_app( + workspace: "ChaGok.xcworkspace", + scheme: "App", + clean: true, + export_method: "app-store", + xcargs: "CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=78QTJM9AD7 CODE_SIGNING_REQUIRED=YES MARKETING_VERSION=#{marketing_version} CURRENT_PROJECT_VERSION=#{options[:build_number]}", + export_options: { + method: "app-store", + teamID: "78QTJM9AD7", + signingStyle: "manual", + provisioningProfiles: { + "com.yongms.ChaGokChaGok" => "match AppStore com.yongms.ChaGokChaGok" + } + } + ) + end +end diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 00000000..12a7dfc6 --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,12 @@ +git_url("https://github.com/Cha-Gok/Match.git") + +storage_mode("git") + +type("development") # The default type, can be: appstore, adhoc, enterprise or development + +readonly(true) + +# For all available options run `fastlane match --help` +# Remove the # in the beginning of the line to enable the other options + +# The docs are available on https://docs.fastlane.tools/actions/match