diff --git a/.codex/skills/souzip-commit/SKILL.md b/.codex/skills/souzip-commit/SKILL.md index 7ae7ccd4..0673aff4 100644 --- a/.codex/skills/souzip-commit/SKILL.md +++ b/.codex/skills/souzip-commit/SKILL.md @@ -14,15 +14,34 @@ Use this skill only when the user explicitly asks to commit. - `docs/harness/workflow/operating-sequence.md` - `docs/harness/templates/plan/plan-template.md` +## Never Commit (로컬 전용) + +아래 파일은 worktree에 있어도 **stage·commit 금지**입니다. `git add -A` / `git add .` 사용하지 말고, 경로를 지정해 stage 하세요. + +| 경로 | 이유 | +|------|------| +| `docs/lint-format-changelog.html` | lint/format 변경 설명용 로컬 HTML | +| `docs/goals/**` | Goal·Story·Plan 실행 문서 (로컬 보관) | +| `Config/*.xcconfig` | 시크릿 (`docs/harness/constitution.md`) | +| `tuist generate` 산출물 | `*.xcodeproj`, `*.xcworkspace`, `Derived/` | + +제외 목록에 걸린 파일이 이미 staged 되어 있으면 커밋 전에 반드시 unstage 합니다. + +```bash +git restore --staged +``` + ## Workflow 1. Confirm the user explicitly said "커밋해" or an equivalent direct commit instruction. 2. Inspect changed files. -3. Separate unrelated changes from the Story/Plan scope. -4. Check the latest verification result. -5. If verification is missing, report that before committing. -6. Draft a commit message using the existing Souzip convention. -7. Stage and commit only the intended files. +3. **Apply [Never Commit](#never-commit-로컬-전용)** — 제외 대상은 stage하지 않고, staged 되어 있으면 unstage. +4. Separate unrelated changes from the Story/Plan scope. +5. Check the latest verification result. +6. If verification is missing, report that before committing. +7. Draft a commit message using the existing Souzip convention. +8. Stage intended files only (`git add …`). `git add -A` / `git add .` 금지. +9. Commit. ## Commit Convention @@ -53,6 +72,7 @@ Types: ## Boundaries - Do not commit without explicit user instruction. +- Do not stage or commit [Never Commit](#never-commit-로컬-전용) paths — even if the user or a prior step created them. - Do not include unrelated user changes. - Do not push. - Do not create a PR. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 00000000..4f3056e8 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,70 @@ +name: Verify + +on: + pull_request: + branches: + - develop + push: + branches: + - develop + +concurrency: + group: verify-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup mise + uses: jdx/mise-action@v2 + + - name: Trust and install tools + run: | + mise trust + mise install + + - name: SwiftFormat lint + run: swiftformat . --config .swiftformat --lint + + - name: SwiftLint (strict) + run: docs/harness/scripts/swiftlint-strict.sh + + build: + name: Build + needs: lint + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup mise + uses: jdx/mise-action@v2 + + - name: Trust and install tools + run: | + mise trust + mise install + + - name: Prepare CI config + env: + DEBUG_XCCONFIG: ${{ secrets.SOUZIP_DEBUG_XCCONFIG }} + RELEASE_XCCONFIG: ${{ secrets.SOUZIP_RELEASE_XCCONFIG }} + GOOGLE_SERVICE_DEBUG: ${{ secrets.SOUZIP_GOOGLE_SERVICE_DEBUG }} + GOOGLE_SERVICE_RELEASE: ${{ secrets.SOUZIP_GOOGLE_SERVICE_RELEASE }} + run: docs/harness/scripts/prepare-ci-config.sh + + - name: Tuist install and generate + run: | + tuist install + tuist generate + + - name: Tuist build + run: tuist build "수집-Debug" + + - name: Tuist test + run: tuist test --skip-ui-tests --no-upload diff --git a/.swiftlint.yml b/.swiftlint.yml index 1fb84928..0d2dc50c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,6 +3,8 @@ disabled_rules: - trailing_whitespace - todo - multiple_closures_with_trailing_closure + - trailing_comma + - file_length # ===== Opt-in 규칙 ===== opt_in_rules: @@ -38,10 +40,6 @@ type_body_length: warning: 300 error: 450 -file_length: - warning: 500 - error: 700 - cyclomatic_complexity: warning: 15 error: 20 diff --git a/CLAUDE.md b/CLAUDE.md index 8e8511d2..ce0623dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,8 @@ **Tuist**로 관리되는 iOS 프로젝트입니다. Swift 5.9, iOS 16.0+ 배포 타겟. ```bash +mise trust # 최초 1회 (worktree/클론 경로마다 필요) +mise install # swiftformat, swiftlint, tuist 설치 tuist install # 외부 의존성 다운로드 tuist generate # xcworkspace, xcodeproj 파일 생성 swiftformat . # 코드 포맷 diff --git a/Projects/Core/Analytics/Sources/AnalyticsEvent.swift b/Projects/Core/Analytics/Sources/AnalyticsEvent.swift index f3b5ff38..fe6cc3a0 100644 --- a/Projects/Core/Analytics/Sources/AnalyticsEvent.swift +++ b/Projects/Core/Analytics/Sources/AnalyticsEvent.swift @@ -59,15 +59,15 @@ public enum AnalyticsEvent { public var eventType: String { switch self { - case let .app(e): e.eventType - case let .upload(e): e.eventType + case let .app(event): event.eventType + case let .upload(event): event.eventType } } public var properties: [String: Any]? { switch self { - case let .app(e): e.properties - case let .upload(e): e.properties + case let .app(event): event.properties + case let .upload(event): event.properties } } } diff --git a/Projects/Data/Sources/Souvenir/DTO/SouvenirDTOMapper.swift b/Projects/Data/Sources/Souvenir/DTO/SouvenirDTOMapper.swift index 79882fe4..873b7512 100644 --- a/Projects/Data/Sources/Souvenir/DTO/SouvenirDTOMapper.swift +++ b/Projects/Data/Sources/Souvenir/DTO/SouvenirDTOMapper.swift @@ -5,9 +5,9 @@ public enum SouvenirDTOMapper { // MARK: - Detail Response to Domain public static func toDomain(_ dto: SouvenirDetailResponse) -> SouvenirDetail { - var localPrice: Int? = nil - var currencySymbol: String? = nil - var krwPrice: Int? = nil + var localPrice: Int? + var currencySymbol: String? + var krwPrice: Int? if let price = dto.price { let original = price.original diff --git a/Projects/Presentation/Sources/Scene/Auth/Login/LoginViewModel.swift b/Projects/Presentation/Sources/Scene/Auth/Login/LoginViewModel.swift index 899bc7b4..690365d5 100644 --- a/Projects/Presentation/Sources/Scene/Auth/Login/LoginViewModel.swift +++ b/Projects/Presentation/Sources/Scene/Auth/Login/LoginViewModel.swift @@ -36,12 +36,12 @@ final class LoginViewModel: BaseViewModel< case let .tapLogin(provider): Task { emit(.loading(true)) - await Login(provider) + await performLogin(provider) emit(.loading(false)) } case .tapGuest: - Task { await Login(nil) } + Task { await performLogin(nil) } } } @@ -52,7 +52,7 @@ final class LoginViewModel: BaseViewModel< mutate { $0.recentAuthProvider = provider } } - private func Login(_ provider: AuthProvider?) async { + private func performLogin(_ provider: AuthProvider?) async { do { let result = try await login.execute(provider: provider) switch result { diff --git a/Projects/Presentation/Sources/Scene/Discovery/Discovery/Base/DiscoveryView.swift b/Projects/Presentation/Sources/Scene/Discovery/Discovery/Base/DiscoveryView.swift index 74d32928..e3699d0c 100644 --- a/Projects/Presentation/Sources/Scene/Discovery/Discovery/Base/DiscoveryView.swift +++ b/Projects/Presentation/Sources/Scene/Discovery/Discovery/Base/DiscoveryView.swift @@ -114,140 +114,139 @@ final class DiscoveryView: BaseView { // MARK: - Diffable / CellRegistration +private struct DiscoveryCellRegistrations { + let countryChip: UICollectionView.CellRegistration + let souvenirCard: UICollectionView.CellRegistration + let categoryChip: UICollectionView.CellRegistration + let moreButton: UICollectionView.CellRegistration + let rankCard: UICollectionView.CellRegistration + let empty: UICollectionView.CellRegistration + let spacer: UICollectionView.CellRegistration + let banner: UICollectionView.CellRegistration + let header: UICollectionView.SupplementaryRegistration +} + private extension DiscoveryView { - func configureDataSource() { - let countryChipRegistration = UICollectionView.CellRegistration< - CountryChipCell, - CountryChipItem - > { cell, _, item in - cell.render(item: item) - } + func makeCellRegistrations() -> DiscoveryCellRegistrations { + DiscoveryCellRegistrations( + countryChip: .init { cell, _, item in + cell.render(item: item) + }, + souvenirCard: .init { cell, _, item in + cell.render(item: item) + }, + categoryChip: .init { cell, _, item in + cell.render(item: item) + }, + moreButton: .init { cell, _, item in + cell.render(title: item) + }, + rankCard: .init { cell, _, item in + cell.render(item) + }, + empty: .init { cell, _, text in + cell.render(text) + }, + spacer: .init { _, _, _ in }, + banner: .init { [weak self] cell, _, _ in + guard let viewController = self?.window?.rootViewController else { return } + cell.configure(rootViewController: viewController) + }, + header: .init( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak self] supplementaryView, _, indexPath in + guard let self, + let section = dataSource?.sectionIdentifier(for: indexPath.section) + else { return } + + supplementaryView.render(section: section) + } + ) + } - let souvenirCardRegistration = UICollectionView.CellRegistration< - SouvenirCardCell, - SouvenirCardItem - > { cell, _, item in - cell.render(item: item) - } + func dequeueConfiguredCell( + collectionView: UICollectionView, + indexPath: IndexPath, + item: Item, + registrations: DiscoveryCellRegistrations + ) -> UICollectionViewCell { + switch item { + case let .countryChip(chipItem): + collectionView.dequeueConfiguredReusableCell( + using: registrations.countryChip, + for: indexPath, + item: chipItem + ) - let categoryChipRegistration = UICollectionView.CellRegistration< - DiscoveryCategoryChipCell, - CategoryItem - > { cell, _, item in - cell.render(item: item) - } + case let .souvenirCard(cardItem): + collectionView.dequeueConfiguredReusableCell( + using: registrations.souvenirCard, + for: indexPath, + item: cardItem + ) - let moreButtonRegistration = UICollectionView.CellRegistration< - MoreButtonCell, - String - > { cell, _, item in - cell.render(title: item) - } + case let .categoryChip(chipItem): + collectionView.dequeueConfiguredReusableCell( + using: registrations.categoryChip, + for: indexPath, + item: chipItem + ) - let rankCardRegistration = UICollectionView.CellRegistration< - RankCardCell, - [StatCountryChipItem] - > { cell, _, item in - cell.render(item) - } + case let .moreButton(title): + collectionView.dequeueConfiguredReusableCell( + using: registrations.moreButton, + for: indexPath, + item: title + ) - let emptyRegistration = UICollectionView.CellRegistration< - EmptyStateCell, - String - > { cell, _, text in - cell.render(text) - } + case let .statCountryChip(chipItem): + collectionView.dequeueConfiguredReusableCell( + using: registrations.rankCard, + for: indexPath, + item: chipItem + ) - let spacerRegistration = UICollectionView.CellRegistration< - UICollectionViewCell, - Void - > { _, _, _ in } - - let bannerRegistration = UICollectionView.CellRegistration< - BannerCell, - Void - > { [weak self] cell, _, _ in - guard let vc = self?.window?.rootViewController else { return } - cell.configure(rootViewController: vc) - } + case let .empty(_, text): + collectionView.dequeueConfiguredReusableCell( + using: registrations.empty, + for: indexPath, + item: text + ) - let headerRegistration = UICollectionView.SupplementaryRegistration< - DiscoverySectionHeaderView - >( - elementKind: UICollectionView.elementKindSectionHeader - ) { [weak self] supplementaryView, _, indexPath in - guard let self, - let section = dataSource?.sectionIdentifier(for: indexPath.section) - else { return } + case .banner: + collectionView.dequeueConfiguredReusableCell( + using: registrations.banner, + for: indexPath, + item: () + ) - supplementaryView.render(section: section) + case .spacer: + collectionView.dequeueConfiguredReusableCell( + using: registrations.spacer, + for: indexPath, + item: () + ) } + } + + func configureDataSource() { + let registrations = makeCellRegistrations() dataSource = .init( collectionView: collectionView - ) { collectionView, indexPath, item in - switch item { - case let .countryChip(chipItem): - collectionView.dequeueConfiguredReusableCell( - using: countryChipRegistration, - for: indexPath, - item: chipItem - ) - - case let .souvenirCard(cardItem): - collectionView.dequeueConfiguredReusableCell( - using: souvenirCardRegistration, - for: indexPath, - item: cardItem - ) - - case let .categoryChip(chipItem): - collectionView.dequeueConfiguredReusableCell( - using: categoryChipRegistration, - for: indexPath, - item: chipItem - ) - - case let .moreButton(title): - collectionView.dequeueConfiguredReusableCell( - using: moreButtonRegistration, - for: indexPath, - item: title - ) - - case let .statCountryChip(chipItem): - collectionView.dequeueConfiguredReusableCell( - using: rankCardRegistration, - for: indexPath, - item: chipItem - ) - - case let .empty(_, text): - collectionView.dequeueConfiguredReusableCell( - using: emptyRegistration, - for: indexPath, - item: text - ) - - case .banner: - collectionView.dequeueConfiguredReusableCell( - using: bannerRegistration, - for: indexPath, - item: () - ) - - case .spacer: - collectionView.dequeueConfiguredReusableCell( - using: spacerRegistration, - for: indexPath, - item: () - ) - } + ) { [weak self] collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + return dequeueConfiguredCell( + collectionView: collectionView, + indexPath: indexPath, + item: item, + registrations: registrations + ) } dataSource?.supplementaryViewProvider = { collectionView, _, indexPath in collectionView.dequeueConfiguredReusableSupplementary( - using: headerRegistration, + using: registrations.header, for: indexPath ) } @@ -266,40 +265,58 @@ private extension DiscoveryView { static let horizontal: CGFloat = 20 } + func isSectionEmpty(_ kind: Section) -> Bool { + guard let dataSource else { return false } + let items = dataSource.snapshot().itemIdentifiers(inSection: kind) + guard items.count == 1 else { return false } + if case .empty = items[0] { return true } + return false + } + + func sectionLayout(for kind: Section, isEmptySection: Bool) -> NSCollectionLayoutSection { + switch kind { + case .top10CountryChips: + makeTop10ChipsSectionLayout() + + case .top10Cards: + if isEmptySection { + makeEmptyFullWidthSectionLayout(height: 219) + } else { + makeTop10CardsSectionLayout() + } + + case .banner: + makeBannerSectionLayout() + + case .categoryChips: + makeCategoryChipsSectionLayout() + + case .categoryCards: + if isEmptySection { + makeEmptyFullWidthSectionLayout(height: 219) + } else { + makeCategoryCardsSectionLayout() + } + + case .categoryMore: + makeCategoryMoreSectionLayout() + + case .statisticsChips: + makeStatisticsChipsSectionLayout() + + case .spacer: + makeSpacerSectionLayout() + } + } + func makeCVLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in guard let self, let kind = dataSource?.sectionIdentifier(for: sectionIndex) else { return nil } - let isEmptySection: Bool = { - guard let ds = self.dataSource else { return false } - let snapshot = ds.snapshot() - let items = snapshot.itemIdentifiers(inSection: kind) - return items.count == 1 && { - if case .empty = items[0] { return true } - return false - }() - }() - - let section = switch kind { - case .top10CountryChips: - makeTop10ChipsSectionLayout() - case .top10Cards: - isEmptySection ? makeEmptyFullWidthSectionLayout(height: 219) : makeTop10CardsSectionLayout() - case .banner: - makeBannerSectionLayout() - case .categoryChips: - makeCategoryChipsSectionLayout() - case .categoryCards: - isEmptySection ? makeEmptyFullWidthSectionLayout(height: 219) : makeCategoryCardsSectionLayout() - case .categoryMore: - makeCategoryMoreSectionLayout() - case .statisticsChips: - makeStatisticsChipsSectionLayout() - case .spacer: - makeSpacerSectionLayout() - } + let isEmptySection = isSectionEmpty(kind) + let section = sectionLayout(for: kind, isEmptySection: isEmptySection) section.contentInsets.top = topSpacing(for: kind) section.contentInsets.bottom = bottomSpacing(for: kind) diff --git a/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendIntent.swift b/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendIntent.swift index e1fc4f57..4b8f5072 100644 --- a/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendIntent.swift +++ b/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendIntent.swift @@ -36,26 +36,12 @@ struct RecommendState { extension RecommendState { var sectionModels: [RecommendSectionModel] { - makeSectionModels( - countries: countries, - preferredSouvenirs: preferredSouvenirs, - uploadSouvenirs: uploadSouvenirs, - uploadCountryName: uploadCountryName, - isPreferredExpanded: isPreferredExpanded, - isUploadExpanded: isUploadExpanded - ) + makeSectionModels() } } private extension RecommendState { - func makeSectionModels( - countries: [CountryChipItem], - preferredSouvenirs: [SouvenirCardItem], - uploadSouvenirs: [SouvenirCardItem], - uploadCountryName: String?, - isPreferredExpanded: Bool, - isUploadExpanded: Bool - ) -> [RecommendSectionModel] { + func makeSectionModels() -> [RecommendSectionModel] { var models: [RecommendSectionModel] = [] // 1) 선호 카테고리 기반 - chips (항상) diff --git a/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendView.swift b/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendView.swift index a06eba5e..612ecd33 100644 --- a/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendView.swift +++ b/Projects/Presentation/Sources/Scene/Discovery/Recommend/Base/RecommendView.swift @@ -117,125 +117,128 @@ final class RecommendView: BaseView { // MARK: - DataSource Configuration -private extension RecommendView { - func configureDataSource() { - let countryChipRegistration = UICollectionView.CellRegistration< - CountryChipCell, - CountryChipItem - > { cell, _, item in - cell.render(item: item) - } - - let souvenirCardRegistration = UICollectionView.CellRegistration< - SouvenirCardCell, - SouvenirCardItem - > { cell, _, item in - cell.render(item: item) - } - - let moreButtonRegistration = UICollectionView.CellRegistration< - MoreButtonCell, - String - > { cell, _, title in - cell.render(title: title) - } +private struct RecommendCellRegistrations { + let countryChip: UICollectionView.CellRegistration + let souvenirCard: UICollectionView.CellRegistration + let moreButton: UICollectionView.CellRegistration + let uploadPrompt: UICollectionView.CellRegistration + let spacer: UICollectionView.CellRegistration + let empty: UICollectionView.CellRegistration + let header: UICollectionView.SupplementaryRegistration +} - let uploadPromptRegistration = UICollectionView.CellRegistration< - UploadPromptCell, - Void - > { cell, _, _ in - cell.action - .bind { [weak self] in - self?.action.accept(.uploadButtonTap) +private extension RecommendView { + func makeCellRegistrations() -> RecommendCellRegistrations { + RecommendCellRegistrations( + countryChip: .init { cell, _, item in + cell.render(item: item) + }, + souvenirCard: .init { cell, _, item in + cell.render(item: item) + }, + moreButton: .init { cell, _, title in + cell.render(title: title) + }, + uploadPrompt: .init { [weak self] cell, _, _ in + cell.action + .bind { [weak self] in + self?.action.accept(.uploadButtonTap) + } + .disposed(by: cell.disposeBag) + }, + spacer: .init { _, _, _ in }, + empty: .init { cell, _, text in + cell.render(text) + }, + header: .init( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak self] supplementaryView, _, indexPath in + guard let self, + let section = dataSource?.sectionIdentifier(for: indexPath.section) + else { return } + + switch section { + case .preferredMore, .uploadMore, .uploadEmpty, .spacer: + supplementaryView.isHidden = true + supplementaryView.render(title: "") + + default: + supplementaryView.isHidden = false + supplementaryView.render(title: section.title) } - .disposed(by: cell.disposeBag) - } + } + ) + } + + func dequeueConfiguredCell( + collectionView: UICollectionView, + indexPath: IndexPath, + item: Item, + registrations: RecommendCellRegistrations + ) -> UICollectionViewCell { + switch item { + case let .countryChip(countryItem): + collectionView.dequeueConfiguredReusableCell( + using: registrations.countryChip, + for: indexPath, + item: countryItem + ) - let spacerRegistration = UICollectionView.CellRegistration< - UICollectionViewCell, - Void - > { _, _, _ in } + case let .souvenirCard(cardItem): + collectionView.dequeueConfiguredReusableCell( + using: registrations.souvenirCard, + for: indexPath, + item: cardItem + ) - let emptyRegistration = UICollectionView.CellRegistration< - EmptyStateCell, - String - > { cell, _, text in - cell.render(text) - } + case let .moreButton(title): + collectionView.dequeueConfiguredReusableCell( + using: registrations.moreButton, + for: indexPath, + item: title + ) - let headerRegistration = UICollectionView.SupplementaryRegistration< - RecommendSectionHeaderView - >( - elementKind: UICollectionView.elementKindSectionHeader - ) { [weak self] supplementaryView, _, indexPath in - guard let self, - let section = dataSource?.sectionIdentifier(for: indexPath.section) - else { return } + case .uploadPrompt: + collectionView.dequeueConfiguredReusableCell( + using: registrations.uploadPrompt, + for: indexPath, + item: () + ) - // 헤더 없는 섹션은 여기서 early return - switch section { - case .preferredMore, .uploadMore, .uploadEmpty, .spacer: - supplementaryView.isHidden = true - supplementaryView.render(title: "") - return + case .spacer: + collectionView.dequeueConfiguredReusableCell( + using: registrations.spacer, + for: indexPath, + item: () + ) - default: - supplementaryView.isHidden = false - supplementaryView.render(title: section.title) - } + case let .empty(_, text): + collectionView.dequeueConfiguredReusableCell( + using: registrations.empty, + for: indexPath, + item: text + ) } + } + + func configureDataSource() { + let registrations = makeCellRegistrations() dataSource = .init( collectionView: collectionView - ) { collectionView, indexPath, item in - switch item { - case let .countryChip(countryItem): - collectionView.dequeueConfiguredReusableCell( - using: countryChipRegistration, - for: indexPath, - item: countryItem - ) - - case let .souvenirCard(cardItem): - collectionView.dequeueConfiguredReusableCell( - using: souvenirCardRegistration, - for: indexPath, - item: cardItem - ) - - case let .moreButton(title): - collectionView.dequeueConfiguredReusableCell( - using: moreButtonRegistration, - for: indexPath, - item: title - ) - - case .uploadPrompt: - collectionView.dequeueConfiguredReusableCell( - using: uploadPromptRegistration, - for: indexPath, - item: () - ) - - case .spacer: - collectionView.dequeueConfiguredReusableCell( - using: spacerRegistration, - for: indexPath, - item: () - ) - - case let .empty(_, text): - collectionView.dequeueConfiguredReusableCell( - using: emptyRegistration, - for: indexPath, - item: text - ) - } + ) { [weak self] collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + return dequeueConfiguredCell( + collectionView: collectionView, + indexPath: indexPath, + item: item, + registrations: registrations + ) } dataSource?.supplementaryViewProvider = { collectionView, _, indexPath in collectionView.dequeueConfiguredReusableSupplementary( - using: headerRegistration, + using: registrations.header, for: indexPath ) } @@ -255,51 +258,55 @@ private extension RecommendView { static let moreBottom: CGFloat = 40 } - func makeCVLayout() -> UICollectionViewLayout { - UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in - guard let self, - let section = dataSource?.sectionIdentifier(for: sectionIndex) - else { return nil } + func isSectionEmpty(_ section: Section) -> Bool { + guard let dataSource else { return false } + let items = dataSource.snapshot().itemIdentifiers(inSection: section) + guard items.count == 1 else { return false } + if case .empty = items[0] { return true } + return false + } - let isEmptySection: Bool = { - guard let ds = self.dataSource else { return false } - let snapshot = ds.snapshot() - let items = snapshot.itemIdentifiers(inSection: section) - guard items.count == 1 else { return false } - if case .empty = items[0] { return true } - return false - }() + func sectionLayout(for section: Section, isEmptySection: Bool) -> NSCollectionLayoutSection { + switch section { + case .preferredCategoryChips: + makeChipsSectionLayout(hasHeader: true) - let layoutSection: NSCollectionLayoutSection = switch section { - case .preferredCategoryChips: - makeChipsSectionLayout(hasHeader: true) + case .preferredCategoryCards: + if isEmptySection { + makeEmptyFullWidthSectionLayout(height: 219, hasHeader: false) + } else { + makeCardsSectionLayout(hasHeader: false) + } - case .preferredCategoryCards: - isEmptySection - ? makeEmptyFullWidthSectionLayout(height: 219, hasHeader: false) - : makeCardsSectionLayout(hasHeader: false) + case .preferredMore: + makeMoreButtonSectionLayout() - case .preferredMore: - makeMoreButtonSectionLayout() + case .spacer: + makeSpacerSectionLayout() - case .spacer: - makeSpacerSectionLayout() + case .uploadEmpty: + makeUploadEmptySectionLayout(hasHeader: false) - case .uploadEmpty: - makeUploadEmptySectionLayout(hasHeader: false) + case .uploadBasedCards: + makeCardsSectionLayout(hasHeader: true) + + case .uploadMore: + makeMoreButtonSectionLayout() + } + } - case .uploadBasedCards: - makeCardsSectionLayout(hasHeader: true) + func makeCVLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self, + let section = dataSource?.sectionIdentifier(for: sectionIndex) + else { return nil } - case .uploadMore: - makeMoreButtonSectionLayout() - } + let isEmptySection = isSectionEmpty(section) + let layoutSection = sectionLayout(for: section, isEmptySection: isEmptySection) - // insets layoutSection.contentInsets.top = topSpacing(for: section) layoutSection.contentInsets.bottom = bottomSpacing(for: section) - // horizontal insets: spacer는 전체폭, 나머지는 20 switch section { case .spacer: layoutSection.contentInsets.leading = 0 diff --git a/Projects/Presentation/Sources/Scene/Home/Globe/Base/GlobeViewModel.swift b/Projects/Presentation/Sources/Scene/Home/Globe/Base/GlobeViewModel.swift index 5e8c6f4c..3a5f922a 100644 --- a/Projects/Presentation/Sources/Scene/Home/Globe/Base/GlobeViewModel.swift +++ b/Projects/Presentation/Sources/Scene/Home/Globe/Base/GlobeViewModel.swift @@ -66,15 +66,7 @@ final class GlobeViewModel: BaseViewModel< handleSouvenirDetailSelection(item) case .wantToUploadSouvenir: - Task { - await authSessionStore.refreshSession() - let isLogin = authSessionStore.isFullyAuthenticatedValue - if isLogin { - navigate(to: .souvenirRoute(.create)) - } else { - navigate(to: .loginBottomSheet) - } - } + handleUploadSouvenirTap() case .wantToClose: handleCloseTap() @@ -458,6 +450,18 @@ private extension GlobeViewModel { // MARK: - Navigation private extension GlobeViewModel { + func handleUploadSouvenirTap() { + Task { + await authSessionStore.refreshSession() + let isLogin = authSessionStore.isFullyAuthenticatedValue + if isLogin { + navigate(to: .souvenirRoute(.create)) + } else { + navigate(to: .loginBottomSheet) + } + } + } + func handleCloseTap() { transitionToGlobe() } diff --git a/Projects/Presentation/Sources/Scene/Home/Globe/SubView/View/SouvenirSheetView.swift b/Projects/Presentation/Sources/Scene/Home/Globe/SubView/View/SouvenirSheetView.swift index 5ac25f4d..cd97469e 100644 --- a/Projects/Presentation/Sources/Scene/Home/Globe/SubView/View/SouvenirSheetView.swift +++ b/Projects/Presentation/Sources/Scene/Home/Globe/SubView/View/SouvenirSheetView.swift @@ -272,16 +272,16 @@ final class SouvenirSheetView: UIView { /// 시트를 키운 방향이면 현재 높이 이상 앵커 중 최소(올림), 줄이면 이하 중 최대(내림). 변화가 작으면 가장 가까운 앵커. private func snapAfterPanEnded() { - let h = currentHeight - let delta = h - panStartHeight + let height = currentHeight + let delta = height - panStartHeight let anchors = [minHeight, midHeight, maxHeight].sorted() let target: CGFloat = if abs(delta) < SnapMetric.directionAmbiguousThreshold { - anchors.min { abs($0 - h) < abs($1 - h) } ?? midHeight + anchors.min { abs($0 - height) < abs($1 - height) } ?? midHeight } else if delta > 0 { - anchors.first { $0 >= h - 0.5 } ?? maxHeight + anchors.first { $0 >= height - 0.5 } ?? maxHeight } else { - anchors.last { $0 <= h + 0.5 } ?? minHeight + anchors.last { $0 <= height + 0.5 } ?? minHeight } setHeight(clamp(target, minHeight, maxHeight), animated: true) @@ -311,7 +311,7 @@ final class SouvenirSheetView: UIView { // MARK: - Utils - private func clamp(_ x: CGFloat, _ a: CGFloat, _ b: CGFloat) -> CGFloat { - min(max(x, a), b) + private func clamp(_ value: CGFloat, _ lowerBound: CGFloat, _ upperBound: CGFloat) -> CGFloat { + min(max(value, lowerBound), upperBound) } } diff --git a/Projects/Presentation/Sources/Scene/MyPage/MyPage/Base/MyPageView.swift b/Projects/Presentation/Sources/Scene/MyPage/MyPage/Base/MyPageView.swift index bf979222..035af52d 100644 --- a/Projects/Presentation/Sources/Scene/MyPage/MyPage/Base/MyPageView.swift +++ b/Projects/Presentation/Sources/Scene/MyPage/MyPage/Base/MyPageView.swift @@ -7,7 +7,7 @@ final class MyPageView: BaseView { private let navigationBar = DSNavigationBar( title: "마이컬렉션", - style: .Settings + style: .trailingSettings ) private let myPageRootListView = MyPageRootListView() diff --git a/Projects/Presentation/Sources/Scene/MyPage/MyPage/SubView/MyCollectionView.swift b/Projects/Presentation/Sources/Scene/MyPage/MyPage/SubView/MyCollectionView.swift index 55387d98..d3fb5a3e 100644 --- a/Projects/Presentation/Sources/Scene/MyPage/MyPage/SubView/MyCollectionView.swift +++ b/Projects/Presentation/Sources/Scene/MyPage/MyPage/SubView/MyCollectionView.swift @@ -85,8 +85,8 @@ final class MyCollectionView: BaseView { DispatchQueue.main.async { [weak self] in guard let self else { return } collectionView.layoutIfNeeded() - let h = collectionView.collectionViewLayout.collectionViewContentSize.height - heightConstraint?.update(offset: h) + let height = collectionView.collectionViewLayout.collectionViewContentSize.height + heightConstraint?.update(offset: height) superview?.layoutIfNeeded() } } diff --git a/Projects/Presentation/Sources/Scene/MyPage/Notice/Base/NoticeListViewController.swift b/Projects/Presentation/Sources/Scene/MyPage/Notice/Base/NoticeListViewController.swift index 5f83d8f1..0e8460df 100644 --- a/Projects/Presentation/Sources/Scene/MyPage/Notice/Base/NoticeListViewController.swift +++ b/Projects/Presentation/Sources/Scene/MyPage/Notice/Base/NoticeListViewController.swift @@ -34,7 +34,11 @@ final class NoticeListViewController: BaseViewController< override func handleEvent(_ event: Event) { switch event { case let .loading(isLoading): - isLoading ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() + if isLoading { + loadingIndicator.startAnimating() + } else { + loadingIndicator.stopAnimating() + } case let .showAlert(message): showDSAlert(message: message) diff --git a/Projects/Presentation/Sources/Scene/MyPage/NoticeDetail/NoticeDetailViewController.swift b/Projects/Presentation/Sources/Scene/MyPage/NoticeDetail/NoticeDetailViewController.swift index d4b7ad15..379729a4 100644 --- a/Projects/Presentation/Sources/Scene/MyPage/NoticeDetail/NoticeDetailViewController.swift +++ b/Projects/Presentation/Sources/Scene/MyPage/NoticeDetail/NoticeDetailViewController.swift @@ -35,7 +35,11 @@ final class NoticeDetailViewController: BaseViewController< override func handleEvent(_ event: Event) { switch event { case let .loading(isLoading): - isLoading ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() + if isLoading { + loadingIndicator.startAnimating() + } else { + loadingIndicator.stopAnimating() + } case let .showAlert(message): showDSAlert(message: message) diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Detail/Detail/Base/SouvenirDetailView.swift b/Projects/Presentation/Sources/Scene/Souvernir/Detail/Detail/Base/SouvenirDetailView.swift index a9cba7b5..4ca16f4b 100644 --- a/Projects/Presentation/Sources/Scene/Souvernir/Detail/Detail/Base/SouvenirDetailView.swift +++ b/Projects/Presentation/Sources/Scene/Souvernir/Detail/Detail/Base/SouvenirDetailView.swift @@ -5,6 +5,7 @@ import Kingfisher import SnapKit import UIKit +// swiftlint:disable:next type_body_length final class SouvenirDetailView: BaseView { // MARK: - Types diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormView.swift b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormView.swift index 630197a1..3c24dc4a 100644 --- a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormView.swift +++ b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormView.swift @@ -5,6 +5,18 @@ import RxCocoa import SnapKit import UIKit +struct SouvenirFormPhotoRenderLine { + let mode: SouvenirFormMode + let localPhotos: [LocalPhoto] + let existingFiles: [SouvenirFile] +} + +struct SouvenirFormAddressRenderLine { + let address: String + let locationDetail: String + let showsPreciseLocationEntry: Bool +} + final class SouvenirFormView: BaseView { // MARK: - UI @@ -161,12 +173,12 @@ final class SouvenirFormView: BaseView { naviBar.render(title: title, style: .close) } - func renderPhotos(_ line: (SouvenirFormMode, [LocalPhoto], [SouvenirFile])) { - switch line.0 { + func renderPhotos(_ line: SouvenirFormPhotoRenderLine) { + switch line.mode { case .create: - photoSectionView.renderCreate(localPhotos: line.1) + photoSectionView.renderCreate(localPhotos: line.localPhotos) case .edit: - photoSectionView.renderEdit(existingFiles: line.2) + photoSectionView.renderEdit(existingFiles: line.existingFiles) } } @@ -174,12 +186,11 @@ final class SouvenirFormView: BaseView { nameFieldView.setText(text) } - /// `(주소, 상세·힌트 문구, 정밀 위치 진입 표시 여부)` - func renderAddress(_ line: (String, String, Bool)) { - addressFieldView.render(text: line.0) + func renderAddress(_ line: SouvenirFormAddressRenderLine) { + addressFieldView.render(text: line.address) addressFieldView.renderDetailOrHintLine( - detailText: line.1, - showPreciseLocationEntry: line.2 + detailText: line.locationDetail, + showPreciseLocationEntry: line.showsPreciseLocationEntry ) } diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewController.swift b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewController.swift index 9ba01c2c..6254ea1a 100644 --- a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewController.swift +++ b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewController.swift @@ -34,7 +34,13 @@ final class SouvenirFormViewController: BaseViewController< .onNext(contentView.renderTitle) observeState() - .map { ($0.mode, $0.localPhotos, $0.existingFiles) } + .map { + SouvenirFormPhotoRenderLine( + mode: $0.mode, + localPhotos: $0.localPhotos, + existingFiles: $0.existingFiles + ) + } .onNext(contentView.renderPhotos) observe(\.name) @@ -42,7 +48,13 @@ final class SouvenirFormViewController: BaseViewController< .onNext(contentView.renderName) observeState() - .map { ($0.address, $0.locationDetail, $0.coordinate != nil) } + .map { + SouvenirFormAddressRenderLine( + address: $0.address, + locationDetail: $0.locationDetail, + showsPreciseLocationEntry: $0.coordinate != nil + ) + } .onNext(contentView.renderAddress) observeState() diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Actions.swift b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Actions.swift new file mode 100644 index 00000000..43c4f700 --- /dev/null +++ b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Actions.swift @@ -0,0 +1,151 @@ +import Domain +import UIKit + +extension SouvenirFormViewModel { + func markDirtyIfNeeded(for action: Action) { + switch action { + case .tapClose, .confirmClose, .tapSubmit, + .tapPhotoAdd, .tapAddress, .tapPreciseLocation, .tapCategory: + break + default: + isDirty = true + } + } + + func handleCloseAction(_ action: Action) { + switch action { + case .tapClose: + if isDirty { + emit(.showConfirmClose) + } else { + navigate(to: .dismiss) + navigate(to: .finish) + } + + case .confirmClose: + navigate(to: .dismiss) + navigate(to: .finish) + + default: + break + } + } + + func handlePhotoAction(_ action: Action) { + switch action { + case .tapPhotoAdd: + guard case .create = state.value.mode else { return } + emit(.showImagePicker) + + case let .addLocalPhotos(photos): + handleAddLocalPhotos(photos) + + case let .removeLocalPhoto(id): + handleRemoveLocalPhoto(id) + + default: + break + } + } + + func handleUpdateName(_ text: String) { + let wasEmpty = state.value.name.isEmpty + mutate { state in + if text.count <= 30 { + state.name = text + } + } + if wasEmpty, !text.isEmpty { + trackUploadOnce(.upload(.titleAdded)) + } + } + + func handleTapPreciseLocation() { + guard let coordinate = state.value.coordinate else { return } + navigate(to: .locationPicker( + initialCoordinate: coordinate.toCLLocationCoordinate2D, + onComplete: { [weak self] clCoordinate, detail in + self?.handleAction(.updateAddress( + clCoordinate.toCoordinate, + detail + )) + } + )) + } + + func handleTapAddress() { + navigate(to: .search(.init( + initialQuery: locationSearchQuery, + mode: .store, + onResult: { [weak self] items, selectedItem, searchText in + self?.locationSearchQuery = searchText + let orderedItems = Self.searchResultsPlacingSelectedFirst( + items, + selected: selectedItem + ) + self?.navigate( + to: .locationSearchResult( + items: orderedItems, + searchText: searchText, + centerCoordinate: selectedItem.coordinate, + onConfirm: { [weak self] confirmedItem in + // 상세주소는 피커에서만 입력; 검색 결과 이름은 locationDetail에 넣지 않음 + self?.handleAction(.updateAddress( + confirmedItem.coordinate.toCoordinate, + "" + )) + } + ) + ) + } + ))) + } + + func handleUpdateAddress(coordinate: Coordinate, detail: String) { + mutate { state in + state.coordinate = coordinate + state.locationDetail = detail + } + trackUploadOnce(.upload(.locationSet)) + + addressLookupGeneration += 1 + let generation = addressLookupGeneration + let lookupCoordinate = coordinate + Task { [weak self] in + await self?.resolveAddressIfLatest( + coordinate: lookupCoordinate, + generation: generation + ) + } + } + + func handleUpdateCurrencySymbol(_ symbol: String) { + mutate { state in + state.currencySymbol = symbol + } + } + + func handleSelectPurpose(_ purpose: SouvenirPurpose) { + mutate { state in + state.purpose = purpose + } + trackUploadOnce(.upload(.targetAdded)) + } + + func handleTapCategory() { + let category = state.value.category + + navigate(to: .categoryPicker( + initailCategory: category + ) { [weak self] selected in + self?.handleAction(.selectCategory(selected)) + }) + } + + func handleSelectCategory(_ category: SouvenirCategory) { + mutate { state in + state.category = category + } + trackUploadOnce(.upload(.categoryAdded)) + } +} diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+ImageProcessing.swift b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+ImageProcessing.swift new file mode 100644 index 00000000..6194a635 --- /dev/null +++ b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+ImageProcessing.swift @@ -0,0 +1,86 @@ +import UIKit + +extension SouvenirFormViewModel { + func convertPhotosToData(_ photos: [LocalPhoto]) throws -> [Data] { + var results: [Data] = [] + results.reserveCapacity(photos.count) + + for photo in photos { + try autoreleasepool { + guard FileManager.default.fileExists(atPath: photo.url.path) else { + throw ImageProcessingError.invalidSource + } + + guard let jpegData = resizeImageFromFile(at: photo.url, maxDimension: 3000, compressionQuality: 0.75) else { + throw ImageProcessingError.jpegConversionFailed + } + + results.append(jpegData) + } + } + + return results + } + + func resizeImageFromFile( + at url: URL, + maxDimension: CGFloat, + compressionQuality: CGFloat + ) -> Data? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + return nil + } + + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let pixelWidth = properties[kCGImagePropertyPixelWidth] as? Int, + let pixelHeight = properties[kCGImagePropertyPixelHeight] as? Int else { + return nil + } + + let needsResize = pixelWidth > Int(maxDimension) || pixelHeight > Int(maxDimension) + + let cgImage: CGImage? + + if needsResize { + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimension, + kCGImageSourceShouldCacheImmediately: true, + ] + cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + } else { + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max(pixelWidth, pixelHeight), + kCGImageSourceShouldCacheImmediately: true, + ] + cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + } + + guard let finalCGImage = cgImage else { + return nil + } + + let uiImage = UIImage(cgImage: finalCGImage) + return uiImage.jpegData(compressionQuality: compressionQuality) + } +} + +enum ImageProcessingError: LocalizedError { + case invalidSource + case thumbnailCreationFailed + case jpegConversionFailed + + var errorDescription: String? { + switch self { + case .invalidSource: + "사진을 불러오는 데 실패했어요." + case .thumbnailCreationFailed: + "사진을 처리하는 중 문제가 발생했어요." + case .jpegConversionFailed: + "사진을 저장 형식으로 변환하지 못했어요." + } + } +} diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Submit.swift b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Submit.swift new file mode 100644 index 00000000..040bf6a0 --- /dev/null +++ b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Submit.swift @@ -0,0 +1,86 @@ +import Domain +import UIKit + +extension SouvenirFormViewModel { + func handleSubmit() { + guard let input = makeSubmitInput() else { + emit(.showError("필수 정보를 모두 입력해주세요.")) + return + } + + switch state.value.mode { + case .create: + trackUploadOnce(.upload(.complete)) + handleCreate(input: input) + + case let .edit(original): + handleUpdate(id: original.id, input: input) + } + } + + func makeSubmitInput() -> SouvenirInput? { + let currentState = state.value + + guard let coordinate = currentState.coordinate, + let category = currentState.category + else { return nil } + + let price: Int? = currentState.price.isEmpty ? nil : Int(currentState.price) + let currencyCode: String? = if currentState.currencySymbol == "₩" { + "KRW" + } else { + try? loadCountryDetail + .execute(countryCode: currentState.countryCode) + .currency + .code + } + + return SouvenirInput( + name: currentState.name, + price: price, + currencyCode: currencyCode, + description: currentState.description, + address: currentState.address, + locationDetail: currentState.locationDetail.isEmpty ? nil : currentState.locationDetail, + coordinate: coordinate, + category: category, + purpose: currentState.purpose, + countryCode: currentState.countryCode + ) + } + + func handleCreate(input: SouvenirInput) { + Task { + do { + emit(.loading(true)) + let imageData = try convertPhotosToData(state.value.localPhotos) + _ = try await createSouvenir.execute( + input: input, + images: imageData + ) + userSouvenirInvalidationStore.notifyUserSouvenirsChanged() + emit(.loading(false)) + navigate(to: .dismiss) + } catch { + emit(.loading(false)) + emit(.showError(error.localizedDescription)) + } + } + } + + func handleUpdate(id: Int, input: SouvenirInput) { + Task { + do { + emit(.loading(true)) + let souvenirDetail = try await updateSouvenir.execute(id: id, input: input) + userSouvenirInvalidationStore.notifyUserSouvenirsChanged() + onResult?(souvenirDetail) + emit(.loading(false)) + navigate(to: .dismiss) + } catch { + emit(.loading(false)) + emit(.showError(error.localizedDescription)) + } + } + } +} diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Supporting.swift b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Supporting.swift new file mode 100644 index 00000000..1b0764b7 --- /dev/null +++ b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel+Supporting.swift @@ -0,0 +1,112 @@ +import Analytics +import Domain +import UIKit + +extension SouvenirFormViewModel { + /// 옵션 B: `countryCode`가 있으면 국가 메타 `currency.symbol`로 `localCurrencySymbol`을 갱신한다. 실패 시 `SouvenirFormState` 초기값 유지(편집: `detail.price.localCurrencySymbol` → `$`, 생성: `$`). + func applyLocalCurrencySymbolFromCountryIfAvailable(countryCode: String) { + guard !countryCode.isEmpty else { return } + do { + let country = try loadCountryDetail.execute(countryCode: countryCode) + mutate { $0.localCurrencySymbol = country.currency.symbol } + } catch { + // 국가 조회 실패 — 폴백은 `SouvenirFormState` 편집 초기화에 위임. 로그는 네트워크/번들 계층 정책에 따름. + } + } + + func handleAddLocalPhotos(_ photos: [LocalPhoto]) { + let wasEmpty = state.value.localPhotos.isEmpty + mutate { state in + guard case .create = state.mode else { return } + let remaining = max(0, 5 - state.localPhotos.count) + state.localPhotos.append(contentsOf: photos.prefix(remaining)) + } + if wasEmpty, !state.value.localPhotos.isEmpty { + trackUploadOnce(.upload(.photoAdded)) + } + } + + func handleRemoveLocalPhoto(_ id: UUID) { + mutate { state in + guard case .create = state.mode else { return } + state.localPhotos.removeAll { $0.id == id } + } + } + + func resolveAddressIfLatest(coordinate: Coordinate, generation: UInt64) async { + do { + let locationAddress = try await loadLocationAddress.execute( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + + let country = try loadCountryDetail.execute(countryCode: locationAddress.countryCode) + await MainActor.run { [weak self] in + guard let self else { return } + guard generation == addressLookupGeneration else { return } + guard state.value.coordinate == coordinate else { return } + mutate { + $0.address = locationAddress.address + $0.currencySymbol = country.currency.symbol + $0.localCurrencySymbol = country.currency.symbol + $0.countryCode = locationAddress.countryCode + } + } + } catch { + await MainActor.run { [weak self] in + guard let self else { return } + guard generation == addressLookupGeneration else { return } + guard state.value.coordinate == coordinate else { return } + emit(.showError(error.localizedDescription)) + } + } + } + + func handleUpdatePrice(_ text: String) { + let wasEmpty = state.value.price.isEmpty + let filtered = text.filter(\.isNumber) + mutate { state in + state.price = filtered + } + if wasEmpty, !filtered.isEmpty { + trackUploadOnce(.upload(.priceAdded)) + } + } + + func handleUpdateDescription(_ text: String) { + let wasEmpty = state.value.description.isEmpty + mutate { state in + if text.count <= 2000 { + state.description = text + } + } + if wasEmpty, !text.isEmpty { + trackUploadOnce(.upload(.introduceAdded)) + } + } + + /// 업로드 퍼널 이벤트를 폼 세션 당 1회만 발송 + func trackUploadOnce(_ event: AnalyticsEvent) { + guard case .create = state.value.mode else { return } + guard !firedUploadEvents.contains(event.eventType) else { return } + firedUploadEvents.insert(event.eventType) + AnalyticsManager.shared.track(event: event) + } +} + +// MARK: - 위치 검색 결과 순서 + +extension SouvenirFormViewModel { + /// 탭한 항목만 맨 앞으로, 나머지는 기존 상대 순서 유지 + static func searchResultsPlacingSelectedFirst( + _ items: [SearchResultItem], + selected: SearchResultItem + ) -> [SearchResultItem] { + guard let index = items.firstIndex(where: { $0.id == selected.id }) else { + return items + } + var rest = items + let chosen = rest.remove(at: index) + return [chosen] + rest + } +} diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel.swift b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel.swift index dac76f0e..5d7e59d4 100644 --- a/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel.swift +++ b/Projects/Presentation/Sources/Scene/Souvernir/Form/Base/SouvenirFormViewModel.swift @@ -14,25 +14,25 @@ final class SouvenirFormViewModel: BaseViewModel< > { // MARK: - UseCase - private let loadCountryDetail: LoadCountryDetailUseCase - private let loadLocationAddress: LoadLocationAddressUseCase - private let createSouvenir: CreateSouvenirUseCase - private let updateSouvenir: UpdateSouvenirUseCase - private let userSouvenirInvalidationStore: UserSouvenirInvalidationStore + let loadCountryDetail: LoadCountryDetailUseCase + let loadLocationAddress: LoadLocationAddressUseCase + let createSouvenir: CreateSouvenirUseCase + let updateSouvenir: UpdateSouvenirUseCase + let userSouvenirInvalidationStore: UserSouvenirInvalidationStore - private let onResult: ((SouvenirDetail) -> Void)? + let onResult: ((SouvenirDetail) -> Void)? /// 검색화면 재진입 시 유지할 마지막 검색어 - private var locationSearchQuery: String = "" + var locationSearchQuery: String = "" /// 업로드 퍼널 이벤트 중복 발송 방지 - private var firedUploadEvents: Set = [] + var firedUploadEvents: Set = [] /// 폼에 입력 이벤트가 한 번이라도 발생했는지 여부 - private var isDirty: Bool = false + var isDirty: Bool = false /// 역지오코딩 요청 세대 — 늦게 도착한 응답이 최신 좌표를 덮어쓰지 않게 함 - private var addressLookupGeneration: UInt64 = 0 + var addressLookupGeneration: UInt64 = 0 // MARK: - Life Cycle @@ -67,417 +67,35 @@ final class SouvenirFormViewModel: BaseViewModel< // MARK: - Action Handling override func handleAction(_ action: Action) { - switch action { - case .tapClose, .confirmClose, .tapSubmit, - .tapPhotoAdd, .tapAddress, .tapPreciseLocation, .tapCategory: - break - default: - isDirty = true - } + markDirtyIfNeeded(for: action) switch action { - case .tapClose: - if isDirty { - emit(.showConfirmClose) - } else { - navigate(to: .dismiss) - navigate(to: .finish) - } - - case .confirmClose: - navigate(to: .dismiss) - navigate(to: .finish) - - case .tapPhotoAdd: - guard case .create = state.value.mode else { return } - emit(.showImagePicker) - - case let .addLocalPhotos(photos): - handleAddLocalPhotos(photos) - - case let .removeLocalPhoto(id): - handleRemoveLocalPhoto(id) - + case .tapClose, .confirmClose: + handleCloseAction(action) + case .tapPhotoAdd, .addLocalPhotos, .removeLocalPhoto: + handlePhotoAction(action) case let .updateName(text): - let wasEmpty = state.value.name.isEmpty - mutate { state in - if text.count <= 30 { - state.name = text - } - } - if wasEmpty, !text.isEmpty { - trackUploadOnce(.upload(.titleAdded)) - } - + handleUpdateName(text) case .tapPreciseLocation: - guard let coordinate = state.value.coordinate else { return } - navigate(to: .locationPicker( - initialCoordinate: coordinate.toCLLocationCoordinate2D, - onComplete: { [weak self] clCoordinate, detail in - self?.handleAction(.updateAddress( - clCoordinate.toCoordinate, - detail - )) - } - )) - - // 주소 입력 탭 처리 + handleTapPreciseLocation() case .tapAddress: - navigate(to: .search(.init( - initialQuery: locationSearchQuery, - mode: .store, - onResult: { [weak self] items, selectedItem, searchText in - self?.locationSearchQuery = searchText - let orderedItems = Self.searchResultsPlacingSelectedFirst( - items, - selected: selectedItem - ) - self?.navigate( - to: .locationSearchResult( - items: orderedItems, - searchText: searchText, - centerCoordinate: selectedItem.coordinate, - onConfirm: { [weak self] confirmedItem in - // 상세주소는 피커에서만 입력; 검색 결과 이름은 locationDetail에 넣지 않음 - self?.handleAction(.updateAddress( - confirmedItem.coordinate.toCoordinate, - "" - )) - } - ) - ) - } - ))) - + handleTapAddress() case let .updateAddress(coordinate, detail): - mutate { state in - state.coordinate = coordinate - state.locationDetail = detail - } - trackUploadOnce(.upload(.locationSet)) - - addressLookupGeneration += 1 - let generation = addressLookupGeneration - let lookupCoordinate = coordinate - Task { [weak self] in - await self?.resolveAddressIfLatest( - coordinate: lookupCoordinate, - generation: generation - ) - } - + handleUpdateAddress(coordinate: coordinate, detail: detail) case let .updateLocalPrice(text): handleUpdatePrice(text) - case let .updateCurrencySymbol(symbol): - mutate { state in - state.currencySymbol = symbol - } - + handleUpdateCurrencySymbol(symbol) case let .selectPurpose(purpose): - mutate { state in - state.purpose = purpose - } - trackUploadOnce(.upload(.targetAdded)) - + handleSelectPurpose(purpose) case .tapCategory: - let category = state.value.category - - navigate(to: .categoryPicker( - initailCategory: category - ) { [weak self] selected in - self?.handleAction(.selectCategory(selected)) - }) - + handleTapCategory() case let .selectCategory(category): - mutate { state in - state.category = category - } - trackUploadOnce(.upload(.categoryAdded)) - + handleSelectCategory(category) case let .updateDescription(text): handleUpdateDescription(text) - case .tapSubmit: handleSubmit() } } - - // MARK: - Private - - /// 옵션 B: `countryCode`가 있으면 국가 메타 `currency.symbol`로 `localCurrencySymbol`을 갱신한다. 실패 시 `SouvenirFormState` 초기값 유지(편집: `detail.price.localCurrencySymbol` → `$`, 생성: `$`). - private func applyLocalCurrencySymbolFromCountryIfAvailable(countryCode: String) { - guard !countryCode.isEmpty else { return } - do { - let country = try loadCountryDetail.execute(countryCode: countryCode) - mutate { $0.localCurrencySymbol = country.currency.symbol } - } catch { - // 국가 조회 실패 — 폴백은 `SouvenirFormState` 편집 초기화에 위임. 로그는 네트워크/번들 계층 정책에 따름. - } - } - - private func handleAddLocalPhotos(_ photos: [LocalPhoto]) { - let wasEmpty = state.value.localPhotos.isEmpty - mutate { state in - guard case .create = state.mode else { return } - let remaining = max(0, 5 - state.localPhotos.count) - state.localPhotos.append(contentsOf: photos.prefix(remaining)) - } - if wasEmpty, !state.value.localPhotos.isEmpty { - trackUploadOnce(.upload(.photoAdded)) - } - } - - private func handleRemoveLocalPhoto(_ id: UUID) { - mutate { state in - guard case .create = state.mode else { return } - state.localPhotos.removeAll { $0.id == id } - } - } - - private func resolveAddressIfLatest(coordinate: Coordinate, generation: UInt64) async { - do { - let locationAddress = try await loadLocationAddress.execute( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - - let country = try loadCountryDetail.execute(countryCode: locationAddress.countryCode) - await MainActor.run { [weak self] in - guard let self else { return } - guard generation == addressLookupGeneration else { return } - guard state.value.coordinate == coordinate else { return } - mutate { - $0.address = locationAddress.address - $0.currencySymbol = country.currency.symbol - $0.localCurrencySymbol = country.currency.symbol - $0.countryCode = locationAddress.countryCode - } - } - } catch { - await MainActor.run { [weak self] in - guard let self else { return } - guard generation == addressLookupGeneration else { return } - guard state.value.coordinate == coordinate else { return } - emit(.showError(error.localizedDescription)) - } - } - } - - private func handleUpdatePrice(_ text: String) { - let wasEmpty = state.value.price.isEmpty - let filtered = text.filter(\.isNumber) - mutate { state in - state.price = filtered - } - if wasEmpty, !filtered.isEmpty { - trackUploadOnce(.upload(.priceAdded)) - } - } - - private func handleUpdateDescription(_ text: String) { - let wasEmpty = state.value.description.isEmpty - mutate { state in - if text.count <= 2000 { - state.description = text - } - } - if wasEmpty, !text.isEmpty { - trackUploadOnce(.upload(.introduceAdded)) - } - } - - private func handleSubmit() { - guard let input = makeSubmitInput() else { - emit(.showError("필수 정보를 모두 입력해주세요.")) - return - } - - switch state.value.mode { - case .create: - trackUploadOnce(.upload(.complete)) - handleCreate(input: input) - - case let .edit(original): - handleUpdate(id: original.id, input: input) - } - } - - // MARK: - Analytics - - /// 업로드 퍼널 이벤트를 폼 세션 당 1회만 발송 - private func trackUploadOnce(_ event: AnalyticsEvent) { - guard case .create = state.value.mode else { return } - guard !firedUploadEvents.contains(event.eventType) else { return } - firedUploadEvents.insert(event.eventType) - AnalyticsManager.shared.track(event: event) - } - - // MARK: - Private Helpers - - private func makeSubmitInput() -> SouvenirInput? { - let currentState = state.value - - guard let coordinate = currentState.coordinate, - let category = currentState.category - else { return nil } - - let price: Int? = currentState.price.isEmpty ? nil : Int(currentState.price) - let currencyCode: String? = if currentState.currencySymbol == "₩" { - "KRW" - } else { - try? loadCountryDetail - .execute(countryCode: currentState.countryCode) - .currency - .code - } - - return SouvenirInput( - name: currentState.name, - price: price, - currencyCode: currencyCode, - description: currentState.description, - address: currentState.address, - locationDetail: currentState.locationDetail.isEmpty ? nil : currentState.locationDetail, - coordinate: coordinate, - category: category, - purpose: currentState.purpose, - countryCode: currentState.countryCode - ) - } - - private func handleCreate(input: SouvenirInput) { - Task { - do { - emit(.loading(true)) - let imageData = try convertPhotosToData(state.value.localPhotos) - _ = try await createSouvenir.execute( - input: input, - images: imageData - ) - userSouvenirInvalidationStore.notifyUserSouvenirsChanged() - emit(.loading(false)) - navigate(to: .dismiss) - } catch { - emit(.loading(false)) - emit(.showError(error.localizedDescription)) - } - } - } - - private func handleUpdate(id: Int, input: SouvenirInput) { - Task { - do { - emit(.loading(true)) - let souvenirDetail = try await updateSouvenir.execute(id: id, input: input) - userSouvenirInvalidationStore.notifyUserSouvenirsChanged() - onResult?(souvenirDetail) - emit(.loading(false)) - navigate(to: .dismiss) - } catch { - emit(.loading(false)) - emit(.showError(error.localizedDescription)) - } - } - } - - private func convertPhotosToData(_ photos: [LocalPhoto]) throws -> [Data] { - var results: [Data] = [] - results.reserveCapacity(photos.count) - - for photo in photos { - try autoreleasepool { - guard FileManager.default.fileExists(atPath: photo.url.path) else { - throw ImageProcessingError.invalidSource - } - - guard let jpegData = resizeImageFromFile(at: photo.url, maxDimension: 3000, compressionQuality: 0.75) else { - throw ImageProcessingError.jpegConversionFailed - } - - results.append(jpegData) - } - } - - return results - } - - private func resizeImageFromFile( - at url: URL, - maxDimension: CGFloat, - compressionQuality: CGFloat - ) -> Data? { - guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { - return nil - } - - guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let pixelWidth = properties[kCGImagePropertyPixelWidth] as? Int, - let pixelHeight = properties[kCGImagePropertyPixelHeight] as? Int else { - return nil - } - - let needsResize = pixelWidth > Int(maxDimension) || pixelHeight > Int(maxDimension) - - let cgImage: CGImage? - - if needsResize { - let options: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxDimension, - kCGImageSourceShouldCacheImmediately: true, - ] - cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) - } else { - let options: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: max(pixelWidth, pixelHeight), - kCGImageSourceShouldCacheImmediately: true, - ] - cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) - } - - guard let finalCGImage = cgImage else { - return nil - } - - let uiImage = UIImage(cgImage: finalCGImage) - return uiImage.jpegData(compressionQuality: compressionQuality) - } -} - -// MARK: - 위치 검색 결과 순서 - -private extension SouvenirFormViewModel { - /// 탭한 항목만 맨 앞으로, 나머지는 기존 상대 순서 유지 - static func searchResultsPlacingSelectedFirst( - _ items: [SearchResultItem], - selected: SearchResultItem - ) -> [SearchResultItem] { - guard let index = items.firstIndex(where: { $0.id == selected.id }) else { - return items - } - var rest = items - let chosen = rest.remove(at: index) - return [chosen] + rest - } -} - -enum ImageProcessingError: LocalizedError { - case invalidSource - case thumbnailCreationFailed - case jpegConversionFailed - - var errorDescription: String? { - switch self { - case .invalidSource: - "사진을 불러오는 데 실패했어요." - case .thumbnailCreationFailed: - "사진을 처리하는 중 문제가 발생했어요." - case .jpegConversionFailed: - "사진을 저장 형식으로 변환하지 못했어요." - } - } } diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryNoResultsView.swift b/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryNoResultsView.swift index 5de6e9f6..93405ed7 100644 --- a/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryNoResultsView.swift +++ b/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryNoResultsView.swift @@ -55,9 +55,9 @@ final class SearchCountryNoResultsView: UIStackView { override func layoutSubviews() { super.layoutSubviews() - let w = bounds.width - layoutMargins.left - layoutMargins.right - guard w > 0 else { return } - primaryLabel.preferredMaxLayoutWidth = w - secondaryLabel.preferredMaxLayoutWidth = w + let width = bounds.width - layoutMargins.left - layoutMargins.right + guard width > 0 else { return } + primaryLabel.preferredMaxLayoutWidth = width + secondaryLabel.preferredMaxLayoutWidth = width } } diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryOnboardingView.swift b/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryOnboardingView.swift index 801b44de..411f1bdc 100644 --- a/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryOnboardingView.swift +++ b/Projects/Presentation/Sources/Scene/Souvernir/Search/Country/SubView/SearchCountryOnboardingView.swift @@ -53,9 +53,9 @@ final class SearchCountryOnboardingView: UIStackView { override func layoutSubviews() { super.layoutSubviews() - let w = bounds.width - guard w > 0 else { return } - messageLabel.preferredMaxLayoutWidth = w + let width = bounds.width + guard width > 0 else { return } + messageLabel.preferredMaxLayoutWidth = width } // MARK: - Public diff --git a/Projects/Presentation/Sources/Scene/Souvernir/Search/Result/Base/LocationSearchResultView.swift b/Projects/Presentation/Sources/Scene/Souvernir/Search/Result/Base/LocationSearchResultView.swift index f6d344a6..4b6d0b9e 100644 --- a/Projects/Presentation/Sources/Scene/Souvernir/Search/Result/Base/LocationSearchResultView.swift +++ b/Projects/Presentation/Sources/Scene/Souvernir/Search/Result/Base/LocationSearchResultView.swift @@ -5,6 +5,7 @@ import RxSwift import SnapKit import UIKit +// swiftlint:disable:next type_body_length final class LocationSearchResultView: BaseView { // MARK: - Types @@ -354,8 +355,8 @@ final class LocationSearchResultView: BaseView { /// 리스트 순 높이(섹션 인셋 포함), `createLayout`과 동일 규칙 private func intrinsicListHeight(itemCount: Int) -> CGFloat { guard itemCount > 0 else { return 0 } - let n = CGFloat(itemCount) - return n * SheetMetric.cellHeight + let rowCount = CGFloat(itemCount) + return rowCount * SheetMetric.cellHeight + CGFloat(max(0, itemCount - 1)) * SheetMetric.interItemSpacing } @@ -429,16 +430,16 @@ final class LocationSearchResultView: BaseView { /// 팬 종료 시: 시트를 키운 방향이면 현재 높이 이상인 앵커 중 가장 낮은 값(올림), 줄인 방향이면 이하 중 가장 높은 값(내림). /// 변화량이 작으면 가장 가까운 앵커(기존 nearest). private func snapSheetAfterPanEnded() { - let h = currentSheetHeight - let delta = h - panStartHeight + let height = currentSheetHeight + let delta = height - panStartHeight let anchors = [SheetMetric.minHeight, midHeight, maxHeight].sorted() let target: CGFloat = if abs(delta) < SheetMetric.sheetSnapDirectionAmbiguousThreshold { - anchors.min { abs($0 - h) < abs($1 - h) } ?? midHeight + anchors.min { abs($0 - height) < abs($1 - height) } ?? midHeight } else if delta > 0 { - anchors.first { $0 >= h - 0.5 } ?? maxHeight + anchors.first { $0 >= height - 0.5 } ?? maxHeight } else { - anchors.last { $0 <= h + 0.5 } ?? SheetMetric.minHeight + anchors.last { $0 <= height + 0.5 } ?? SheetMetric.minHeight } let clamped = max(SheetMetric.minHeight, min(target, maxHeight)) diff --git a/Projects/Shared/DesignSystem/Sources/Component/Navigation/DSNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Component/Navigation/DSNavigationBar.swift index 3158b461..a8258a27 100644 --- a/Projects/Shared/DesignSystem/Sources/Component/Navigation/DSNavigationBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Component/Navigation/DSNavigationBar.swift @@ -8,7 +8,7 @@ public final class DSNavigationBar: UIView { case title case back // back case close // close - case Settings // back + settings + case trailingSettings // settings only (e.g. tab root) case backManage // back + edit, delete case backEtc } @@ -109,7 +109,7 @@ public final class DSNavigationBar: UIView { case .close: renderLeft(.close) - case .Settings: + case .trailingSettings: renderRight([.settings]) case .backManage: diff --git a/Projects/Shared/DesignSystem/Sources/Component/View/DSSpeechBubbleView.swift b/Projects/Shared/DesignSystem/Sources/Component/View/DSSpeechBubbleView.swift index 8db32542..6de5fbf7 100644 --- a/Projects/Shared/DesignSystem/Sources/Component/View/DSSpeechBubbleView.swift +++ b/Projects/Shared/DesignSystem/Sources/Component/View/DSSpeechBubbleView.swift @@ -142,28 +142,28 @@ private extension DSSpeechBubbleView { let midX = rect.midX let tailLeft = midX - Metric.tailWidth / 2 let tailRight = midX + Metric.tailWidth / 2 - let r = min(Metric.cornerRadius, bodyHeight / 2) + let radius = min(Metric.cornerRadius, bodyHeight / 2) let path = UIBezierPath() // 상단 좌측 → 상단 우측 (top edge) - path.move(to: CGPoint(x: r, y: 0)) - path.addLine(to: CGPoint(x: rect.maxX - r, y: 0)) + path.move(to: CGPoint(x: radius, y: 0)) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: 0)) // 상단 우측 모서리 (top-right corner) path.addArc( - withCenter: CGPoint(x: rect.maxX - r, y: r), - radius: r, + withCenter: CGPoint(x: rect.maxX - radius, y: radius), + radius: radius, startAngle: -.pi / 2, endAngle: 0, clockwise: true ) // 우측 edge → 하단 우측 모서리 (right edge + bottom-right corner) - path.addLine(to: CGPoint(x: rect.maxX, y: bodyHeight - r)) + path.addLine(to: CGPoint(x: rect.maxX, y: bodyHeight - radius)) path.addArc( - withCenter: CGPoint(x: rect.maxX - r, y: bodyHeight - r), - radius: r, + withCenter: CGPoint(x: rect.maxX - radius, y: bodyHeight - radius), + radius: radius, startAngle: 0, endAngle: .pi / 2, clockwise: true @@ -175,20 +175,20 @@ private extension DSSpeechBubbleView { path.addLine(to: CGPoint(x: tailLeft, y: bodyHeight)) // 하단 좌측 모서리 (bottom-left corner) - path.addLine(to: CGPoint(x: r, y: bodyHeight)) + path.addLine(to: CGPoint(x: radius, y: bodyHeight)) path.addArc( - withCenter: CGPoint(x: r, y: bodyHeight - r), - radius: r, + withCenter: CGPoint(x: radius, y: bodyHeight - radius), + radius: radius, startAngle: .pi / 2, endAngle: .pi, clockwise: true ) // 좌측 edge → 상단 좌측 모서리 (left edge + top-left corner) - path.addLine(to: CGPoint(x: 0, y: r)) + path.addLine(to: CGPoint(x: 0, y: radius)) path.addArc( - withCenter: CGPoint(x: r, y: r), - radius: r, + withCenter: CGPoint(x: radius, y: radius), + radius: radius, startAngle: .pi, endAngle: -.pi / 2, clockwise: true diff --git a/Projects/Shared/DesignSystem/Sources/Layout/LayoutConstants.swift b/Projects/Shared/DesignSystem/Sources/Layout/LayoutConstants.swift index 117cf195..43a897d6 100644 --- a/Projects/Shared/DesignSystem/Sources/Layout/LayoutConstants.swift +++ b/Projects/Shared/DesignSystem/Sources/Layout/LayoutConstants.swift @@ -40,7 +40,7 @@ public enum LayoutConstants { /// 공통 크기 값 public enum Size { /// 아이콘 크기 - public enum Icon { + public enum Icon { // swiftlint:disable:this nesting public static let xSmall: CGFloat = 16 public static let small: CGFloat = 20 public static let medium: CGFloat = 24 @@ -49,7 +49,7 @@ public enum LayoutConstants { } /// 버튼 높이 - public enum Button { + public enum Button { // swiftlint:disable:this nesting public static let small: CGFloat = 40 public static let medium: CGFloat = 48 public static let large: CGFloat = 56 diff --git a/Workspace.swift b/Workspace.swift index 12b31014..f455b5fc 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -3,6 +3,6 @@ import ProjectDescription let workspace = Workspace( name: "Souzip", projects: [ - "Projects/**" + "Projects/**", ] ) diff --git a/docs/harness/scripts/prepare-ci-config.sh b/docs/harness/scripts/prepare-ci-config.sh new file mode 100755 index 00000000..1b58a5ec --- /dev/null +++ b/docs/harness/scripts/prepare-ci-config.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# GitHub Actions용 Config 파일을 secrets에서 복원합니다. +# secrets가 없으면 로컬 Config/ 파일을 그대로 사용합니다. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$ROOT" + +mkdir -p Config + +decode_secret() { + local name="$1" + local target="$2" + local value="${!name:-}" + + if [ -n "$value" ]; then + echo "$value" | base64 --decode > "$target" + echo "config restored from secret: $target" + return 0 + fi + + return 1 +} + +restored=0 + +if decode_secret DEBUG_XCCONFIG Config/Debug.xcconfig; then + restored=$((restored + 1)) +fi + +if decode_secret RELEASE_XCCONFIG Config/Release.xcconfig; then + restored=$((restored + 1)) +fi + +if decode_secret GOOGLE_SERVICE_DEBUG Config/GoogleService-Info-Debug.plist; then + restored=$((restored + 1)) +fi + +if decode_secret GOOGLE_SERVICE_RELEASE Config/GoogleService-Info-Release.plist; then + restored=$((restored + 1)) +fi + +if [ "$restored" -eq 0 ]; then + if [ -f Config/Debug.xcconfig ] && [ -f Config/Release.xcconfig ]; then + echo "using existing Config/ files" + exit 0 + fi + + echo "ERROR: CI build requires Config files." + echo "Set GitHub secrets (base64-encoded):" + echo " SOUZIP_DEBUG_XCCONFIG" + echo " SOUZIP_RELEASE_XCCONFIG" + echo " SOUZIP_GOOGLE_SERVICE_DEBUG" + echo " SOUZIP_GOOGLE_SERVICE_RELEASE" + echo + echo "Example:" + echo " base64 -i Config/Debug.xcconfig | pbcopy" + exit 1 +fi + +required=( + Config/Debug.xcconfig + Config/Release.xcconfig + Config/GoogleService-Info-Debug.plist + Config/GoogleService-Info-Release.plist +) + +for file in "${required[@]}"; do + if [ ! -f "$file" ]; then + echo "ERROR: missing $file after secret restore" + exit 1 + fi +done diff --git a/docs/harness/scripts/swiftlint-strict.sh b/docs/harness/scripts/swiftlint-strict.sh new file mode 100755 index 00000000..d6d93c5c --- /dev/null +++ b/docs/harness/scripts/swiftlint-strict.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# SwiftLint를 strict 모드로 실행합니다. warning/error 모두 실패합니다. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$ROOT" + +run_swiftlint() { + if command -v swiftlint >/dev/null 2>&1; then + swiftlint "$@" + elif command -v mise >/dev/null 2>&1; then + mise exec -- swiftlint "$@" + elif [ -x "$HOME/.local/bin/mise" ]; then + "$HOME/.local/bin/mise" exec -- swiftlint "$@" + else + echo "ERROR: swiftlint not found and mise is unavailable" + exit 1 + fi +} + +run_swiftlint lint --config .swiftlint.yml --strict diff --git a/docs/harness/scripts/verify.sh b/docs/harness/scripts/verify.sh index 016d4484..ab918855 100755 --- a/docs/harness/scripts/verify.sh +++ b/docs/harness/scripts/verify.sh @@ -61,8 +61,8 @@ echo "=== Souzip verify: $MODE ===" echo "=== SwiftFormat lint ===" run_tool swiftformat . --config .swiftformat --lint -echo "=== SwiftLint ===" -run_tool swiftlint lint --config .swiftlint.yml +echo "=== SwiftLint (strict) ===" +docs/harness/scripts/swiftlint-strict.sh echo "=== Tuist generate ===" run_tool tuist generate diff --git a/docs/harness/skills.md b/docs/harness/skills.md index 715b85f7..2d24cd72 100644 --- a/docs/harness/skills.md +++ b/docs/harness/skills.md @@ -12,7 +12,7 @@ Souzip workflow를 반복 실행하기 위한 Codex project skills입니다. | `souzip-jira-story` | active Story 확정 뒤 Jira Epic·Story 초안·생성·연결이 필요할 때 | 초안 승인·명시 지시 없이 Jira 생성하지 않기 | | `souzip-plan` | 선택된 Story를 한 세션 Plan으로 나눌 때 | “구현해” 전 코드 수정하지 않기 | | `souzip-verify` | Plan 또는 Story 검증과 실패 분류가 필요할 때 | 방금 실행한 검증 없이 완료라고 말하지 않기 | -| `souzip-commit` | 사용자가 “커밋해”라고 했을 때 | 기존 커밋 컨벤션을 벗어나지 않기 | +| `souzip-commit` | 사용자가 “커밋해”라고 했을 때 | 기존 커밋 컨벤션을 벗어나지 않기 · `docs/lint-format-changelog.html` 등 [로컬 전용 파일](../../.codex/skills/souzip-commit/SKILL.md#never-commit-로컬-전용) stage·commit 금지 | | `souzip-pr` | 사용자가 “PR”이라고 했을 때 | 기존 PR 템플릿과 develop 변경 파일 확인을 건너뛰지 않기 | ## 위치 diff --git a/docs/harness/verification.md b/docs/harness/verification.md index f16dc2e6..662de091 100644 --- a/docs/harness/verification.md +++ b/docs/harness/verification.md @@ -6,11 +6,17 @@ Plan은 구현만 끝났다고 닫지 않습니다. ## 기본 검증 - SwiftFormat lint -- SwiftLint +- SwiftLint strict (warning/error 모두 실패) - Tuist generate - Tuist build - 관련 테스트 +PR CI(`.github/workflows/verify.yml`)도 동일한 strict lint 기준을 사용합니다. +로컬 Xcode 빌드는 lint 실패로 막지 않고, PR의 `lint` job에서만 막습니다. + +CI `build` job은 `tuist build` 뒤 `tuist test --skip-ui-tests`까지 실행합니다. +Config secrets가 없으면 `build` job(빌드·테스트)만 실패하고 `lint` job은 통과합니다. + 기본 스크립트: ```bash